mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(ux): complete UX overhaul — design system, onboarding, web polish (#1277)
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8638895879
commit
a09c023642
Generated
+10
-26
@@ -1510,7 +1510,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
||||
dependencies = [
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
||||
dependencies = [
|
||||
"crokey-proc_macros",
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"strict",
|
||||
@@ -1743,7 +1743,7 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
||||
dependencies = [
|
||||
"crossterm 0.29.0",
|
||||
"crossterm",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strict",
|
||||
@@ -1817,22 +1817,6 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@@ -2152,7 +2136,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2339,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3426,7 +3410,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"criterion",
|
||||
"cron",
|
||||
"crossterm 0.28.1",
|
||||
"crossterm",
|
||||
"deadpool-postgres",
|
||||
"dirs 6.0.0",
|
||||
"dotenvy",
|
||||
@@ -4150,7 +4134,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5488,7 +5472,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6395,7 +6379,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8045,7 +8029,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ async-trait = "0.1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Terminal
|
||||
crossterm = "0.28"
|
||||
crossterm = "0.29"
|
||||
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
|
||||
termimad = "0.34"
|
||||
|
||||
|
||||
+26
-2
@@ -317,7 +317,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
.channels
|
||||
.send_status(
|
||||
&self.message.channel,
|
||||
StatusUpdate::Thinking("Calling LLM...".into()),
|
||||
StatusUpdate::Thinking(format!("Thinking (step {iteration})...")),
|
||||
&self.message.metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -435,7 +435,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
.channels
|
||||
.send_status(
|
||||
&self.message.channel,
|
||||
StatusUpdate::Thinking(format!("Executing {} tool(s)...", tool_calls.len())),
|
||||
StatusUpdate::Thinking(contextual_tool_message(&tool_calls)),
|
||||
&self.message.metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -969,6 +969,30 @@ pub(super) fn check_auth_required(
|
||||
Some((name, instructions))
|
||||
}
|
||||
|
||||
/// Build a contextual thinking message based on tool names.
|
||||
///
|
||||
/// Instead of a generic "Executing 2 tool(s)..." this returns messages like
|
||||
/// "Running command..." or "Fetching page..." for single-tool calls, falling
|
||||
/// back to "Executing N tool(s)..." for multi-tool calls.
|
||||
fn contextual_tool_message(tool_calls: &[crate::llm::ToolCall]) -> String {
|
||||
if tool_calls.len() == 1 {
|
||||
match tool_calls[0].name.as_str() {
|
||||
"shell" => "Running command...".into(),
|
||||
"web_fetch" => "Fetching page...".into(),
|
||||
"memory_search" => "Searching memory...".into(),
|
||||
"memory_write" => "Writing to memory...".into(),
|
||||
"memory_read" => "Reading memory...".into(),
|
||||
"http_request" => "Making HTTP request...".into(),
|
||||
"file_read" => "Reading file...".into(),
|
||||
"file_write" => "Writing file...".into(),
|
||||
"json_transform" => "Transforming data...".into(),
|
||||
name => format!("Running {name}..."),
|
||||
}
|
||||
} else {
|
||||
format!("Executing {} tool(s)...", tool_calls.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact messages for retry after a context-length-exceeded error.
|
||||
///
|
||||
/// Keeps all `System` messages (which carry the system prompt and instructions),
|
||||
|
||||
@@ -529,8 +529,8 @@ pub fn normalize_cron_expression(schedule: &str) -> String {
|
||||
let trimmed = schedule.trim();
|
||||
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
match fields.len() {
|
||||
5 => format!("0 {} *", trimmed),
|
||||
6 => format!("{} *", trimmed),
|
||||
5 => format!("0 {} *", fields.join(" ")),
|
||||
6 => format!("{} *", fields.join(" ")),
|
||||
_ => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +556,33 @@ impl Agent {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Emit per-turn cost summary
|
||||
{
|
||||
let usage = self.cost_guard().model_usage().await;
|
||||
let (total_in, total_out, total_cost) =
|
||||
usage
|
||||
.values()
|
||||
.fold((0u64, 0u64, rust_decimal::Decimal::ZERO), |acc, m| {
|
||||
(
|
||||
acc.0 + m.input_tokens,
|
||||
acc.1 + m.output_tokens,
|
||||
acc.2 + m.cost,
|
||||
)
|
||||
});
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::TurnCost {
|
||||
input_tokens: total_in,
|
||||
output_tokens: total_out,
|
||||
cost_usd: format!("${:.4}", total_cost),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
||||
|
||||
+1
-1
@@ -386,7 +386,7 @@ impl AppBuilder {
|
||||
let b = tools
|
||||
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
||||
.await;
|
||||
tracing::info!("Builder mode enabled");
|
||||
tracing::debug!("Builder mode enabled");
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
|
||||
+188
-93
@@ -1,8 +1,11 @@
|
||||
//! Boot screen displayed after all initialization completes.
|
||||
//!
|
||||
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
|
||||
//! state: model, database, tool count, enabled features, active channels,
|
||||
//! and the gateway URL.
|
||||
//! Shows a compact ANSI-styled status panel with three tiers:
|
||||
//! - **Tier 1 (always):** Name + version, model + backend.
|
||||
//! - **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
|
||||
//! - **Tier 3 (removed):** Database, tool count, features → use `ironclaw status`.
|
||||
|
||||
use crate::cli::fmt;
|
||||
|
||||
/// All displayable fields for the boot screen.
|
||||
pub struct BootInfo {
|
||||
@@ -29,112 +32,76 @@ pub struct BootInfo {
|
||||
pub tunnel_url: Option<String>,
|
||||
/// Provider name for the managed tunnel (e.g., "ngrok").
|
||||
pub tunnel_provider: Option<String>,
|
||||
/// Time elapsed during startup. Shown at the bottom when present.
|
||||
pub startup_elapsed: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
/// Print the boot screen to stdout.
|
||||
pub fn print_boot_screen(info: &BootInfo) {
|
||||
// ANSI codes matching existing REPL palette
|
||||
let bold = "\x1b[1m";
|
||||
let cyan = "\x1b[36m";
|
||||
let dim = "\x1b[90m";
|
||||
let yellow = "\x1b[33m";
|
||||
let yellow_underline = "\x1b[33;4m";
|
||||
let reset = "\x1b[0m";
|
||||
const KW: usize = 10;
|
||||
|
||||
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
|
||||
/// Print the boot screen to stdout.
|
||||
///
|
||||
/// **Tier 1 (always):** Name + version, model + backend.
|
||||
/// **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels.
|
||||
/// **Tier 3 (removed):** Database, tool count, features — use `ironclaw status`.
|
||||
pub fn print_boot_screen(info: &BootInfo) {
|
||||
let border = format!(" {}", fmt::separator(58));
|
||||
|
||||
println!();
|
||||
println!("{border}");
|
||||
println!();
|
||||
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
|
||||
|
||||
// ── Tier 1: always shown ──────────────────────────────────────────
|
||||
|
||||
println!(
|
||||
" {}{}{} v{}",
|
||||
fmt::bold(),
|
||||
info.agent_name,
|
||||
fmt::reset(),
|
||||
info.version
|
||||
);
|
||||
println!();
|
||||
|
||||
// Model line
|
||||
let model_display = if let Some(ref cheap) = info.cheap_model {
|
||||
format!(
|
||||
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
|
||||
info.llm_model, cheap
|
||||
"{}{}{} {}cheap{} {}{}{}",
|
||||
fmt::accent(),
|
||||
info.llm_model,
|
||||
fmt::reset(),
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::accent(),
|
||||
cheap,
|
||||
fmt::reset(),
|
||||
)
|
||||
} else {
|
||||
format!("{cyan}{}{reset}", info.llm_model)
|
||||
format!("{}{}{}", fmt::accent(), info.llm_model, fmt::reset())
|
||||
};
|
||||
println!(
|
||||
" {dim}model{reset} {model_display} {dim}via {}{reset}",
|
||||
info.llm_backend
|
||||
" {}{:<width$}{} {model_display} {}via {}{}",
|
||||
fmt::dim(),
|
||||
"model",
|
||||
fmt::reset(),
|
||||
fmt::dim(),
|
||||
info.llm_backend,
|
||||
fmt::reset(),
|
||||
width = KW,
|
||||
);
|
||||
|
||||
// Database line
|
||||
let db_status = if info.db_connected {
|
||||
"connected"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
println!(
|
||||
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
|
||||
info.db_backend
|
||||
);
|
||||
// ── Tier 2: conditional ───────────────────────────────────────────
|
||||
|
||||
// Tools line
|
||||
println!(
|
||||
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
|
||||
info.tool_count
|
||||
);
|
||||
|
||||
// Features line
|
||||
let mut features = Vec::new();
|
||||
if info.embeddings_enabled {
|
||||
if let Some(ref provider) = info.embeddings_provider {
|
||||
features.push(format!("embeddings ({provider})"));
|
||||
} else {
|
||||
features.push("embeddings".to_string());
|
||||
}
|
||||
}
|
||||
if info.heartbeat_enabled {
|
||||
let mins = info.heartbeat_interval_secs / 60;
|
||||
features.push(format!("heartbeat ({mins}m)"));
|
||||
}
|
||||
match info.docker_status {
|
||||
crate::sandbox::detect::DockerStatus::Available => {
|
||||
features.push("sandbox".to_string());
|
||||
}
|
||||
crate::sandbox::detect::DockerStatus::NotInstalled => {
|
||||
features.push(format!("{yellow}sandbox (docker not installed){reset}"));
|
||||
}
|
||||
crate::sandbox::detect::DockerStatus::NotRunning => {
|
||||
features.push(format!("{yellow}sandbox (docker not running){reset}"));
|
||||
}
|
||||
crate::sandbox::detect::DockerStatus::Disabled => {
|
||||
// Don't show sandbox when disabled
|
||||
}
|
||||
}
|
||||
if info.claude_code_enabled {
|
||||
features.push("claude-code".to_string());
|
||||
}
|
||||
if info.routines_enabled {
|
||||
features.push("routines".to_string());
|
||||
}
|
||||
if info.skills_enabled {
|
||||
features.push("skills".to_string());
|
||||
}
|
||||
if !features.is_empty() {
|
||||
println!(
|
||||
" {dim}features{reset} {cyan}{}{reset}",
|
||||
features.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
// Channels line
|
||||
if !info.channels.is_empty() {
|
||||
println!(
|
||||
" {dim}channels{reset} {cyan}{}{reset}",
|
||||
info.channels.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
// Gateway URL (highlighted)
|
||||
// Gateway URL
|
||||
if let Some(ref url) = info.gateway_url {
|
||||
println!();
|
||||
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
|
||||
println!(
|
||||
" {}{:<width$}{} {}{}{}",
|
||||
fmt::dim(),
|
||||
"gateway",
|
||||
fmt::reset(),
|
||||
fmt::link(),
|
||||
url,
|
||||
fmt::reset(),
|
||||
width = KW,
|
||||
);
|
||||
}
|
||||
|
||||
// Tunnel URL
|
||||
@@ -142,15 +109,140 @@ pub fn print_boot_screen(info: &BootInfo) {
|
||||
let provider_tag = info
|
||||
.tunnel_provider
|
||||
.as_deref()
|
||||
.map(|p| format!(" {dim}({p}){reset}"))
|
||||
.map(|p| format!(" {}({}){}", fmt::dim(), p, fmt::reset()))
|
||||
.unwrap_or_default();
|
||||
println!(" {dim}tunnel{reset} {yellow_underline}{url}{reset}{provider_tag}");
|
||||
println!(
|
||||
" {}{:<width$}{} {}{}{}{}",
|
||||
fmt::dim(),
|
||||
"tunnel",
|
||||
fmt::reset(),
|
||||
fmt::link(),
|
||||
url,
|
||||
fmt::reset(),
|
||||
provider_tag,
|
||||
width = KW,
|
||||
);
|
||||
}
|
||||
|
||||
// Non-default channels (skip if only the default set)
|
||||
let non_default: Vec<&str> = info
|
||||
.channels
|
||||
.iter()
|
||||
.filter(|c| !matches!(c.as_str(), "repl" | "gateway"))
|
||||
.map(|c| c.as_str())
|
||||
.collect();
|
||||
if !non_default.is_empty() {
|
||||
println!(
|
||||
" {}{:<width$}{} {}{}{}",
|
||||
fmt::dim(),
|
||||
"channels",
|
||||
fmt::reset(),
|
||||
fmt::accent(),
|
||||
non_default.join(" "),
|
||||
fmt::reset(),
|
||||
width = KW,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier 3: compact feature tags ──────────────────────────────────
|
||||
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
|
||||
// Database
|
||||
if info.db_connected {
|
||||
tags.push(format!("db:{}", info.db_backend));
|
||||
}
|
||||
|
||||
// Tool count
|
||||
if info.tool_count > 0 {
|
||||
tags.push(format!("tools:{}", info.tool_count));
|
||||
}
|
||||
|
||||
// Routines
|
||||
if info.routines_enabled {
|
||||
tags.push("routines".to_string());
|
||||
}
|
||||
|
||||
// Heartbeat with interval
|
||||
if info.heartbeat_enabled {
|
||||
let interval = if info.heartbeat_interval_secs >= 3600
|
||||
&& info.heartbeat_interval_secs.is_multiple_of(3600)
|
||||
{
|
||||
format!("{}h", info.heartbeat_interval_secs / 3600)
|
||||
} else if info.heartbeat_interval_secs >= 60
|
||||
&& info.heartbeat_interval_secs.is_multiple_of(60)
|
||||
{
|
||||
format!("{}m", info.heartbeat_interval_secs / 60)
|
||||
} else {
|
||||
format!("{}s", info.heartbeat_interval_secs)
|
||||
};
|
||||
tags.push(format!("heartbeat:{interval}"));
|
||||
}
|
||||
|
||||
// Skills
|
||||
if info.skills_enabled {
|
||||
tags.push("skills".to_string());
|
||||
}
|
||||
|
||||
// Sandbox / Docker
|
||||
if info.sandbox_enabled {
|
||||
let suffix = match info.docker_status {
|
||||
crate::sandbox::detect::DockerStatus::Available => "",
|
||||
crate::sandbox::detect::DockerStatus::NotRunning => ":stopped",
|
||||
_ => ":unavail",
|
||||
};
|
||||
tags.push(format!("sandbox{suffix}"));
|
||||
}
|
||||
|
||||
// Embeddings
|
||||
if info.embeddings_enabled {
|
||||
if let Some(ref provider) = info.embeddings_provider {
|
||||
tags.push(format!("embeddings:{provider}"));
|
||||
} else {
|
||||
tags.push("embeddings".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code bridge
|
||||
if info.claude_code_enabled {
|
||||
tags.push("claude-code".to_string());
|
||||
}
|
||||
|
||||
if !tags.is_empty() {
|
||||
println!(
|
||||
" {}{:<width$}{} {}",
|
||||
fmt::dim(),
|
||||
"features",
|
||||
fmt::reset(),
|
||||
tags.join(" "),
|
||||
width = KW,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────
|
||||
|
||||
println!();
|
||||
println!("{border}");
|
||||
println!();
|
||||
println!(" /help for commands, /quit to exit");
|
||||
|
||||
// Startup elapsed
|
||||
if let Some(elapsed) = info.startup_elapsed {
|
||||
let millis = elapsed.as_millis();
|
||||
let elapsed_str = if millis < 1000 {
|
||||
format!("{millis}ms")
|
||||
} else {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
format!("{secs:.1}s")
|
||||
};
|
||||
println!(" {}ready in {}{}", fmt::dim(), elapsed_str, fmt::reset());
|
||||
}
|
||||
|
||||
// Hint to run `ironclaw status` for full details
|
||||
println!(
|
||||
" {}Run `ironclaw status` for full system details.{}",
|
||||
fmt::hint(),
|
||||
fmt::reset()
|
||||
);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -187,6 +279,7 @@ mod tests {
|
||||
],
|
||||
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
|
||||
tunnel_provider: Some("ngrok".to_string()),
|
||||
startup_elapsed: None,
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
@@ -216,6 +309,7 @@ mod tests {
|
||||
channels: vec![],
|
||||
tunnel_url: None,
|
||||
tunnel_provider: None,
|
||||
startup_elapsed: None,
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
@@ -245,6 +339,7 @@ mod tests {
|
||||
channels: vec!["repl".to_string()],
|
||||
tunnel_url: None,
|
||||
tunnel_provider: None,
|
||||
startup_elapsed: None,
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
|
||||
@@ -333,6 +333,12 @@ pub enum StatusUpdate {
|
||||
},
|
||||
/// Suggested follow-up messages for the user.
|
||||
Suggestions { suggestions: Vec<String> },
|
||||
/// Per-turn token usage and cost summary (shown as subtle metadata).
|
||||
TurnCost {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cost_usd: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl StatusUpdate {
|
||||
|
||||
+338
-126
@@ -20,6 +20,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -40,6 +41,7 @@ use tokio_stream::wrappers::ReceiverStream;
|
||||
use crate::agent::truncate_for_preview;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::cli::fmt;
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Max characters for tool result previews in the terminal.
|
||||
@@ -119,7 +121,7 @@ impl Hinter for ReplHelper {
|
||||
|
||||
impl Highlighter for ReplHelper {
|
||||
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
||||
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
|
||||
Cow::Owned(format!("{}{hint}{}", fmt::dim(), fmt::reset()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,55 +145,207 @@ impl ConditionalEventHandler for EscInterruptHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Approval action chosen by the interactive selector.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ApprovalAction {
|
||||
Approve,
|
||||
Always,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApprovalAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Approve => write!(f, "Approve (y)"),
|
||||
Self::Always => write!(f, "Always approve (a)"),
|
||||
Self::Deny => write!(f, "Deny (n)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApprovalAction {
|
||||
fn as_input(self) -> &'static str {
|
||||
match self {
|
||||
Self::Approve => "y",
|
||||
Self::Always => "a",
|
||||
Self::Deny => "n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive approval selector using crossterm raw mode.
|
||||
/// Returns the approval action string ("y", "a", or "n").
|
||||
fn run_approval_selector(allow_always: bool) -> Option<&'static str> {
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{self, ClearType},
|
||||
};
|
||||
|
||||
let options: Vec<ApprovalAction> = if allow_always {
|
||||
vec![
|
||||
ApprovalAction::Approve,
|
||||
ApprovalAction::Always,
|
||||
ApprovalAction::Deny,
|
||||
]
|
||||
} else {
|
||||
vec![ApprovalAction::Approve, ApprovalAction::Deny]
|
||||
};
|
||||
|
||||
let num = options.len();
|
||||
let mut sel: usize = 0;
|
||||
// Total lines: options + hint line
|
||||
let total_lines = (num + 1) as u16;
|
||||
|
||||
let render = |sel: usize| {
|
||||
let mut w = io::stderr();
|
||||
let pipe = format!("{}│{}", fmt::accent(), fmt::reset());
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
if i == sel {
|
||||
let _ = write!(w, " {pipe} {}● {opt}{}\r\n", fmt::bold(), fmt::reset());
|
||||
} else {
|
||||
let _ = write!(w, " {pipe} {}○ {opt}{}\r\n", fmt::dim(), fmt::reset());
|
||||
}
|
||||
}
|
||||
let _ = write!(
|
||||
w,
|
||||
" {}└{} {}↑↓ enter to select{}\r\n",
|
||||
fmt::accent(),
|
||||
fmt::reset(),
|
||||
fmt::dim(),
|
||||
fmt::reset()
|
||||
);
|
||||
let _ = w.flush();
|
||||
};
|
||||
|
||||
let _ = terminal::enable_raw_mode();
|
||||
render(sel);
|
||||
|
||||
let result = loop {
|
||||
let Ok(evt) = event::read() else { break None };
|
||||
if let CtEvent::Key(key) = evt {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
CtKeyCode::Up | CtKeyCode::Char('k') => {
|
||||
sel = if sel == 0 { num - 1 } else { sel - 1 };
|
||||
}
|
||||
CtKeyCode::Down | CtKeyCode::Char('j') => {
|
||||
sel = (sel + 1) % num;
|
||||
}
|
||||
CtKeyCode::Enter => break Some(options[sel].as_input()),
|
||||
CtKeyCode::Char('y') | CtKeyCode::Char('Y') => break Some("y"),
|
||||
CtKeyCode::Char('a') | CtKeyCode::Char('A') if allow_always => break Some("a"),
|
||||
CtKeyCode::Char('n') | CtKeyCode::Char('N') => break Some("n"),
|
||||
CtKeyCode::Esc => break None,
|
||||
_ => continue,
|
||||
}
|
||||
// Redraw: move up, clear, render
|
||||
let mut w = io::stderr();
|
||||
let _ = execute!(w, cursor::MoveUp(total_lines));
|
||||
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
|
||||
render(sel);
|
||||
}
|
||||
};
|
||||
|
||||
let _ = terminal::disable_raw_mode();
|
||||
|
||||
// Overwrite selector with the confirmed choice
|
||||
let mut w = io::stderr();
|
||||
let _ = execute!(w, cursor::MoveUp(total_lines));
|
||||
let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown));
|
||||
let (label, color) = if let Some(action) = result {
|
||||
let l = options
|
||||
.iter()
|
||||
.find(|o| o.as_input() == action)
|
||||
.unwrap_or(&options[0]);
|
||||
let c = if action == "n" {
|
||||
fmt::error()
|
||||
} else {
|
||||
fmt::success()
|
||||
};
|
||||
(l.to_string(), c)
|
||||
} else {
|
||||
(ApprovalAction::Deny.to_string(), fmt::error())
|
||||
};
|
||||
let _ = writeln!(
|
||||
w,
|
||||
" {}└{} {color}● {label}{}",
|
||||
fmt::accent(),
|
||||
fmt::reset(),
|
||||
fmt::reset()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Build a termimad skin with our color scheme.
|
||||
fn make_skin() -> MadSkin {
|
||||
let mut skin = MadSkin::default();
|
||||
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
|
||||
skin.bold.set_fg(termimad::crossterm::style::Color::White);
|
||||
skin.italic
|
||||
.set_fg(termimad::crossterm::style::Color::Magenta);
|
||||
skin.inline_code
|
||||
.set_fg(termimad::crossterm::style::Color::Green);
|
||||
skin.code_block
|
||||
.set_fg(termimad::crossterm::style::Color::Green);
|
||||
skin.set_headers_fg(crossterm::style::Color::Yellow);
|
||||
skin.bold.set_fg(crossterm::style::Color::White);
|
||||
skin.italic.set_fg(crossterm::style::Color::Magenta);
|
||||
skin.inline_code.set_fg(crossterm::style::Color::Green);
|
||||
skin.code_block.set_fg(crossterm::style::Color::Green);
|
||||
skin.code_block.left_margin = 2;
|
||||
skin
|
||||
}
|
||||
|
||||
/// Truncate a string to `max_chars` using character boundaries.
|
||||
///
|
||||
/// For strings longer than `max_chars`, shows the first half and last half
|
||||
/// separated by `...` so both ends are visible.
|
||||
fn smart_truncate(s: &str, max_chars: usize) -> Cow<'_, str> {
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_chars {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
// Account for the 3-char "..." separator
|
||||
let budget = max_chars.saturating_sub(3);
|
||||
let head_len = budget / 2;
|
||||
let tail_len = budget - head_len;
|
||||
let head: String = s.chars().take(head_len).collect();
|
||||
let tail: String = s
|
||||
.chars()
|
||||
.skip(char_count.saturating_sub(tail_len))
|
||||
.collect();
|
||||
Cow::Owned(format!("{head}...{tail}"))
|
||||
}
|
||||
|
||||
/// Format JSON params as `key: value` lines for the approval card.
|
||||
fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
|
||||
let max_val_len = fmt::term_width().saturating_sub(8);
|
||||
|
||||
match params {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut lines = Vec::new();
|
||||
for (key, value) in map {
|
||||
let val_str = match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let display = if s.len() > 120 { &s[..120] } else { s };
|
||||
format!("\x1b[32m\"{display}\"\x1b[0m")
|
||||
let display = smart_truncate(s, max_val_len);
|
||||
format!("{}\"{display}\"{}", fmt::success(), fmt::reset())
|
||||
}
|
||||
other => {
|
||||
let rendered = other.to_string();
|
||||
if rendered.len() > 120 {
|
||||
format!("{}...", &rendered[..120])
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
smart_truncate(&rendered, max_val_len).into_owned()
|
||||
}
|
||||
};
|
||||
lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}"));
|
||||
lines.push(format!(
|
||||
"{indent}{}{key}{}: {val_str}",
|
||||
fmt::accent(),
|
||||
fmt::reset()
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
other => {
|
||||
let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string());
|
||||
let truncated = if pretty.len() > 300 {
|
||||
format!("{}...", &pretty[..300])
|
||||
} else {
|
||||
pretty
|
||||
};
|
||||
let truncated = smart_truncate(&pretty, 300);
|
||||
truncated
|
||||
.lines()
|
||||
.map(|l| format!("{indent}\x1b[90m{l}\x1b[0m"))
|
||||
.map(|l| format!("{indent}{}{l}{}", fmt::dim(), fmt::reset()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -210,6 +364,12 @@ pub struct ReplChannel {
|
||||
is_streaming: Arc<AtomicBool>,
|
||||
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
||||
suppress_banner: Arc<AtomicBool>,
|
||||
/// Sender to inject messages into the agent loop (set after start()).
|
||||
msg_tx: Arc<Mutex<Option<mpsc::Sender<IncomingMessage>>>>,
|
||||
/// When true, the readline thread must yield stdin (approval selector or agent processing).
|
||||
stdin_locked: Arc<AtomicBool>,
|
||||
/// Number of transient status lines (Thinking) to erase on next output.
|
||||
transient_lines: std::sync::atomic::AtomicU8,
|
||||
}
|
||||
|
||||
impl ReplChannel {
|
||||
@@ -226,6 +386,9 @@ impl ReplChannel {
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||
msg_tx: Arc::new(Mutex::new(None)),
|
||||
stdin_locked: Arc::new(AtomicBool::new(false)),
|
||||
transient_lines: std::sync::atomic::AtomicU8::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +405,9 @@ impl ReplChannel {
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||
msg_tx: Arc::new(Mutex::new(None)),
|
||||
stdin_locked: Arc::new(AtomicBool::new(false)),
|
||||
transient_lines: std::sync::atomic::AtomicU8::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +419,17 @@ impl ReplChannel {
|
||||
fn is_debug(&self) -> bool {
|
||||
self.debug_mode.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Erase transient status lines (Thinking indicators) from the terminal.
|
||||
fn clear_transient(&self) {
|
||||
use crossterm::{cursor, execute, terminal};
|
||||
let n = self.transient_lines.swap(0, Ordering::Relaxed);
|
||||
if n > 0 {
|
||||
let mut stderr = io::stderr();
|
||||
let _ = execute!(stderr, cursor::MoveUp(n as u16));
|
||||
let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReplChannel {
|
||||
@@ -262,33 +439,30 @@ impl Default for ReplChannel {
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
// Bold white for section headers, bold cyan for commands, dim gray for descriptions
|
||||
let h = "\x1b[1m"; // bold (section headers)
|
||||
let c = "\x1b[1;36m"; // bold cyan (commands)
|
||||
let d = "\x1b[90m"; // dim gray (descriptions)
|
||||
let r = "\x1b[0m"; // reset
|
||||
let h = fmt::bold();
|
||||
let c = fmt::bold_accent();
|
||||
let d = fmt::dim();
|
||||
let r = fmt::reset();
|
||||
let hi = fmt::hint();
|
||||
|
||||
println!();
|
||||
println!(" {h}IronClaw REPL{r}");
|
||||
println!();
|
||||
println!(" {h}Commands{r}");
|
||||
println!(" {c}/help{r} {d}show this help{r}");
|
||||
println!(" {c}/debug{r} {d}toggle verbose output{r}");
|
||||
println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}");
|
||||
println!(" {h}Quick start{r}");
|
||||
println!(" {c}/new{r} {hi}Start a new thread{r}");
|
||||
println!(" {c}/compact{r} {hi}Compress context window{r}");
|
||||
println!(" {c}/quit{r} {hi}Exit{r}");
|
||||
println!();
|
||||
println!(" {h}Conversation{r}");
|
||||
println!(" {c}/undo{r} {d}undo the last turn{r}");
|
||||
println!(" {c}/redo{r} {d}redo an undone turn{r}");
|
||||
println!(" {c}/clear{r} {d}clear conversation{r}");
|
||||
println!(" {c}/compact{r} {d}compact context window{r}");
|
||||
println!(" {c}/new{r} {d}new conversation thread{r}");
|
||||
println!(" {c}/interrupt{r} {d}stop current operation{r}");
|
||||
println!(" {c}esc{r} {d}stop current operation{r}");
|
||||
println!();
|
||||
println!(" {h}Approval responses{r}");
|
||||
println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}");
|
||||
println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}");
|
||||
println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}");
|
||||
println!(" {h}All commands{r}");
|
||||
println!(
|
||||
" {d}Conversation{r} {c}/new{r} {c}/clear{r} {c}/compact{r} {c}/undo{r} {c}/redo{r} {c}/summarize{r} {c}/suggest{r}"
|
||||
);
|
||||
println!(" {d}Threads{r} {c}/thread{r} {c}/resume{r} {c}/list{r}");
|
||||
println!(" {d}Execution{r} {c}/interrupt{r} {d}(esc){r} {c}/cancel{r}");
|
||||
println!(
|
||||
" {d}System{r} {c}/tools{r} {c}/model{r} {c}/version{r} {c}/status{r} {c}/debug{r} {c}/heartbeat{r}"
|
||||
);
|
||||
println!(" {d}Session{r} {c}/help{r} {c}/quit{r}");
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -305,10 +479,15 @@ impl Channel for ReplChannel {
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
// Store tx so send_status can inject approval responses directly
|
||||
if let Ok(mut guard) = self.msg_tx.lock() {
|
||||
*guard = Some(tx.clone());
|
||||
}
|
||||
let single_message = self.single_message.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
||||
let stdin_locked = Arc::clone(&self.stdin_locked);
|
||||
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
||||
|
||||
std::thread::spawn(move || {
|
||||
@@ -357,18 +536,33 @@ impl Channel for ReplChannel {
|
||||
let _ = rl.load_history(&hist_path);
|
||||
|
||||
if !suppress_banner.load(Ordering::Relaxed) {
|
||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||
println!(
|
||||
"{}IronClaw{} /help for commands, /quit to exit",
|
||||
fmt::bold(),
|
||||
fmt::reset()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
loop {
|
||||
// Yield stdin while approval selector or agent processing locks it
|
||||
while stdin_locked.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
|
||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||
"\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m "
|
||||
format!(
|
||||
"{}[debug]{} {}\u{203A}{} ",
|
||||
fmt::warning(),
|
||||
fmt::reset(),
|
||||
fmt::bold_accent(),
|
||||
fmt::reset()
|
||||
)
|
||||
} else {
|
||||
"\x1b[1;36m\u{203A}\x1b[0m "
|
||||
format!("{}\u{203A}{} ", fmt::bold_accent(), fmt::reset())
|
||||
};
|
||||
|
||||
match rl.readline(prompt) {
|
||||
match rl.readline(&prompt) {
|
||||
Ok(line) => {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
@@ -394,9 +588,9 @@ impl Channel for ReplChannel {
|
||||
let current = debug_mode.load(Ordering::Relaxed);
|
||||
debug_mode.store(!current, Ordering::Relaxed);
|
||||
if !current {
|
||||
println!("\x1b[90mdebug mode on\x1b[0m");
|
||||
println!("{}debug mode on{}", fmt::dim(), fmt::reset());
|
||||
} else {
|
||||
println!("\x1b[90mdebug mode off\x1b[0m");
|
||||
println!("{}debug mode off{}", fmt::dim(), fmt::reset());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -405,7 +599,11 @@ impl Channel for ReplChannel {
|
||||
|
||||
let msg =
|
||||
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
|
||||
// Lock stdin before sending so readline doesn't restart
|
||||
// while the agent is processing (approval selector needs stdin)
|
||||
stdin_locked.store(true, Ordering::Relaxed);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
stdin_locked.store(false, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -456,21 +654,23 @@ impl Channel for ReplChannel {
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
.unwrap_or(80);
|
||||
let width = fmt::term_width();
|
||||
|
||||
// If we were streaming, the content was already printed via StreamChunk.
|
||||
// Just finish the line and reset.
|
||||
if self.is_streaming.swap(false, Ordering::Relaxed) {
|
||||
println!();
|
||||
println!();
|
||||
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clear any leftover thinking indicators
|
||||
self.clear_transient();
|
||||
|
||||
// Dim separator line before the response
|
||||
let sep_width = width.min(80);
|
||||
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
||||
eprintln!("{}", fmt::separator(sep_width));
|
||||
|
||||
// Render markdown
|
||||
let skin = make_skin();
|
||||
@@ -478,6 +678,8 @@ impl Channel for ReplChannel {
|
||||
|
||||
print!("{text}");
|
||||
println!();
|
||||
// Unlock stdin so readline can resume
|
||||
self.stdin_locked.store(false, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -490,31 +692,34 @@ impl Channel for ReplChannel {
|
||||
|
||||
match status {
|
||||
StatusUpdate::Thinking(msg) => {
|
||||
self.clear_transient();
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
||||
eprintln!(" {}\u{25CB} {display}{}", fmt::dim(), fmt::reset());
|
||||
self.transient_lines.store(1, Ordering::Relaxed);
|
||||
}
|
||||
StatusUpdate::ToolStarted { name } => {
|
||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||
self.clear_transient();
|
||||
eprintln!(" {}\u{25CB} {name}{}", fmt::dim(), fmt::reset());
|
||||
self.transient_lines.store(1, Ordering::Relaxed);
|
||||
}
|
||||
StatusUpdate::ToolCompleted { name, success, .. } => {
|
||||
self.clear_transient();
|
||||
if success {
|
||||
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
||||
eprintln!(" {}\u{25CF} {name}{}", fmt::success(), fmt::reset());
|
||||
} else {
|
||||
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
|
||||
eprintln!(" {}\u{2717} {name} (failed){}", fmt::error(), fmt::reset());
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolResult { name: _, preview } => {
|
||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
|
||||
}
|
||||
StatusUpdate::StreamChunk(chunk) => {
|
||||
// Print separator on the false-to-true transition
|
||||
if !self.is_streaming.swap(true, Ordering::Relaxed) {
|
||||
let width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
.unwrap_or(80);
|
||||
let sep_width = width.min(80);
|
||||
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
||||
self.clear_transient();
|
||||
let sep_width = fmt::term_width().min(80);
|
||||
eprintln!("{}", fmt::separator(sep_width));
|
||||
}
|
||||
print!("{chunk}");
|
||||
let _ = io::stdout().flush();
|
||||
@@ -525,73 +730,67 @@ impl Channel for ReplChannel {
|
||||
browse_url,
|
||||
} => {
|
||||
eprintln!(
|
||||
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
||||
" {}[job]{} {title} {}({job_id}){} {}{browse_url}{}",
|
||||
fmt::accent(),
|
||||
fmt::reset(),
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::link(),
|
||||
fmt::reset()
|
||||
);
|
||||
}
|
||||
StatusUpdate::Status(msg) => {
|
||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
eprintln!(" {}{display}{}", fmt::dim(), fmt::reset());
|
||||
}
|
||||
}
|
||||
StatusUpdate::ApprovalNeeded {
|
||||
request_id,
|
||||
request_id: _,
|
||||
tool_name,
|
||||
description,
|
||||
description: _,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => {
|
||||
let term_width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
.unwrap_or(80);
|
||||
let box_width = (term_width.saturating_sub(4)).clamp(40, 60);
|
||||
self.clear_transient();
|
||||
let pipe = format!("{}│{}", fmt::accent(), fmt::reset());
|
||||
|
||||
// Short request ID for the bottom border
|
||||
let short_id = if request_id.len() > 8 {
|
||||
&request_id[..8]
|
||||
} else {
|
||||
&request_id
|
||||
};
|
||||
|
||||
// Top border: ┌ tool_name requires approval ───
|
||||
let top_label = format!(" {tool_name} requires approval ");
|
||||
let top_fill = box_width.saturating_sub(top_label.len() + 1);
|
||||
let top_border = format!(
|
||||
"\u{250C}\x1b[33m{top_label}\x1b[0m{}",
|
||||
"\u{2500}".repeat(top_fill)
|
||||
// Header: ◆ tool requires approval
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {}\u{25C6} {}{tool_name}{} requires approval",
|
||||
fmt::accent(),
|
||||
fmt::bold(),
|
||||
fmt::reset()
|
||||
);
|
||||
|
||||
// Bottom border: └─ short_id ─────
|
||||
let bot_label = format!(" {short_id} ");
|
||||
let bot_fill = box_width.saturating_sub(bot_label.len() + 2);
|
||||
let bot_border = format!(
|
||||
"\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}",
|
||||
"\u{2500}".repeat(bot_fill)
|
||||
);
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" {top_border}");
|
||||
eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m");
|
||||
eprintln!(" \u{2502}");
|
||||
|
||||
// Params
|
||||
let param_lines = format_json_params(¶meters, " \u{2502} ");
|
||||
// The format_json_params already includes the indent prefix
|
||||
// but we need to handle the case where each line already starts with it
|
||||
for line in param_lines.lines() {
|
||||
eprintln!("{line}");
|
||||
// Params: │ key value
|
||||
let param_lines = format_json_params(¶meters, &format!(" {pipe} "));
|
||||
if !param_lines.is_empty() {
|
||||
eprintln!(" {pipe}");
|
||||
for line in param_lines.lines() {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(" \u{2502}");
|
||||
if allow_always {
|
||||
eprintln!(
|
||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||
);
|
||||
} else {
|
||||
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
|
||||
}
|
||||
eprintln!(" {bot_border}");
|
||||
eprintln!();
|
||||
eprintln!(" {pipe}");
|
||||
// Run interactive selector directly from send_status
|
||||
// stdin is already locked by Thinking/ToolStarted, so the
|
||||
// readline thread is not competing for stdin.
|
||||
let msg_tx = Arc::clone(&self.msg_tx);
|
||||
let user_id = self.user_id.clone();
|
||||
let lock_flag = Arc::clone(&self.stdin_locked);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let action = run_approval_selector(allow_always).unwrap_or("n");
|
||||
// Unlock stdin so readline can resume after approval
|
||||
lock_flag.store(false, Ordering::Relaxed);
|
||||
let Ok(guard) = msg_tx.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let msg = IncomingMessage::new("repl", &user_id, action);
|
||||
let _ = tx.blocking_send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
@@ -600,12 +799,16 @@ impl Channel for ReplChannel {
|
||||
..
|
||||
} => {
|
||||
eprintln!();
|
||||
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
|
||||
eprintln!(
|
||||
"{} Authentication required for {extension_name}{}",
|
||||
fmt::warning(),
|
||||
fmt::reset()
|
||||
);
|
||||
if let Some(ref instr) = instructions {
|
||||
eprintln!(" {instr}");
|
||||
}
|
||||
if let Some(ref url) = setup_url {
|
||||
eprintln!(" \x1b[4m{url}\x1b[0m");
|
||||
eprintln!(" {}{url}{}", fmt::link(), fmt::reset());
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
@@ -615,21 +818,32 @@ impl Channel for ReplChannel {
|
||||
message,
|
||||
} => {
|
||||
if success {
|
||||
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
|
||||
eprintln!(
|
||||
"{} {extension_name}: {message}{}",
|
||||
fmt::success(),
|
||||
fmt::reset()
|
||||
);
|
||||
} else {
|
||||
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
||||
eprintln!(
|
||||
"{} {extension_name}: {message}{}",
|
||||
fmt::error(),
|
||||
fmt::reset()
|
||||
);
|
||||
}
|
||||
}
|
||||
StatusUpdate::ImageGenerated { path, .. } => {
|
||||
if let Some(ref p) = path {
|
||||
eprintln!("\x1b[36m [image] {p}\x1b[0m");
|
||||
eprintln!("{} [image] {p}{}", fmt::accent(), fmt::reset());
|
||||
} else {
|
||||
eprintln!("\x1b[36m [image generated]\x1b[0m");
|
||||
eprintln!("{} [image generated]{}", fmt::accent(), fmt::reset());
|
||||
}
|
||||
}
|
||||
StatusUpdate::Suggestions { .. } => {
|
||||
// Suggestions are only rendered by the web gateway
|
||||
}
|
||||
StatusUpdate::TurnCost { .. } => {
|
||||
// Cost display is handled by the TUI channel
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -640,11 +854,9 @@ impl Channel for ReplChannel {
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let skin = make_skin();
|
||||
let width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
.unwrap_or(80);
|
||||
let width = fmt::term_width();
|
||||
|
||||
eprintln!("\x1b[34m\u{25CF}\x1b[0m notification");
|
||||
eprintln!("{}\u{25CF}{} notification", fmt::accent(), fmt::reset());
|
||||
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
||||
eprint!("{text}");
|
||||
eprintln!();
|
||||
|
||||
@@ -117,7 +117,7 @@ async fn register_channel(
|
||||
wasm_router: &Arc<WasmChannelRouter>,
|
||||
) -> (String, Box<dyn crate::channels::Channel>) {
|
||||
let channel_name = loaded.name().to_string();
|
||||
tracing::info!("Loaded WASM channel: {}", channel_name);
|
||||
tracing::debug!("Loaded WASM channel: {}", channel_name);
|
||||
let owner_actor_id = config
|
||||
.channels
|
||||
.wasm_channel_owner_ids
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -415,6 +415,16 @@ impl Channel for GatewayChannel {
|
||||
suggestions,
|
||||
thread_id,
|
||||
},
|
||||
StatusUpdate::TurnCost {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost_usd,
|
||||
} => SseEvent::TurnCost {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cost_usd,
|
||||
thread_id,
|
||||
},
|
||||
};
|
||||
|
||||
self.state.sse.broadcast(event);
|
||||
|
||||
@@ -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))
|
||||
|
||||
+582
-84
@@ -61,8 +61,16 @@ if (mql.addEventListener) {
|
||||
mql.addListener(onSchemeChange);
|
||||
}
|
||||
|
||||
// Bind theme toggle button (CSP-compliant — no inline onclick).
|
||||
// Bind theme toggle buttons (CSP-compliant — no inline onclick).
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
document.getElementById('settings-theme-toggle')?.addEventListener('click', () => {
|
||||
toggleTheme();
|
||||
const btn = document.getElementById('settings-theme-toggle');
|
||||
if (btn) {
|
||||
const mode = localStorage.getItem('ironclaw-theme') || 'system';
|
||||
btn.textContent = 'Theme: ' + mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
}
|
||||
});
|
||||
|
||||
let token = '';
|
||||
let eventSource = null;
|
||||
@@ -87,6 +95,19 @@ let authFlowPending = false;
|
||||
let _ghostSuggestion = '';
|
||||
let currentSettingsSubtab = 'inference';
|
||||
|
||||
// --- Streaming Debounce State ---
|
||||
let _streamBuffer = '';
|
||||
let _streamDebounceTimer = null;
|
||||
const STREAM_DEBOUNCE_MS = 50;
|
||||
|
||||
// --- Connection Status Banner State ---
|
||||
let _connectionLostTimer = null;
|
||||
let _connectionLostAt = null;
|
||||
let _reconnectAttempts = 0;
|
||||
|
||||
// --- Send Cooldown State ---
|
||||
let _sendCooldown = false;
|
||||
|
||||
// --- Slash Commands ---
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
@@ -126,12 +147,36 @@ function authenticate() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading state for Connect button
|
||||
const connectBtn = document.getElementById('auth-connect-btn');
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Connecting...';
|
||||
}
|
||||
|
||||
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
||||
apiFetch('/api/chat/threads')
|
||||
.then(() => {
|
||||
sessionStorage.setItem('ironclaw_token', token);
|
||||
document.getElementById('auth-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
const authScreen = document.getElementById('auth-screen');
|
||||
const app = document.getElementById('app');
|
||||
// Cross-fade: fade out auth screen, then show app
|
||||
if (authScreen) authScreen.style.opacity = '0';
|
||||
// Show app container (invisible — opacity:0 in CSS) so layout computes
|
||||
app.style.display = 'flex';
|
||||
// Position tab indicator instantly (no transition) before fade-in
|
||||
const indicator = document.getElementById('tab-indicator');
|
||||
if (indicator) indicator.style.transition = 'none';
|
||||
updateTabIndicator();
|
||||
// Force layout so the instant position is applied, then restore transition
|
||||
if (indicator) {
|
||||
void indicator.offsetLeft;
|
||||
indicator.style.transition = '';
|
||||
}
|
||||
// Now fade in
|
||||
app.classList.add('visible');
|
||||
// Hide auth screen after fade-out transition completes
|
||||
setTimeout(() => { if (authScreen) authScreen.style.display = 'none'; }, 300);
|
||||
// Strip token and log_level from URL so they're not visible in the address bar
|
||||
const cleaned = new URL(window.location);
|
||||
const urlLogLevel = cleaned.searchParams.get('log_level');
|
||||
@@ -155,8 +200,14 @@ function authenticate() {
|
||||
.catch(() => {
|
||||
sessionStorage.removeItem('ironclaw_token');
|
||||
document.getElementById('auth-screen').style.display = '';
|
||||
document.getElementById('auth-screen').style.opacity = '';
|
||||
document.getElementById('app').style.display = 'none';
|
||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
||||
// Reset Connect button on error
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Connect';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,29 +215,8 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') authenticate();
|
||||
});
|
||||
|
||||
// --- Static element event bindings (CSP-compliant, no inline handlers) ---
|
||||
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
|
||||
document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
|
||||
document.getElementById('language-btn').addEventListener('click', () => toggleLanguageMenu());
|
||||
// Language option clicks handled by delegated data-action="switch-language" handler.
|
||||
document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
|
||||
document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
|
||||
document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
|
||||
document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
|
||||
document.getElementById('send-btn').addEventListener('click', () => sendMessage());
|
||||
document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
|
||||
document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
|
||||
document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
|
||||
document.getElementById('logs-server-level').addEventListener('change', function() { setServerLogLevel(this.value); });
|
||||
document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
|
||||
document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
|
||||
document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
|
||||
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
|
||||
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
|
||||
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
||||
// Note: main event listener registration is at the bottom of this file (search
|
||||
// "Event Listener Registration"). Do NOT add duplicate listeners here.
|
||||
|
||||
// Auto-authenticate from URL param or saved session
|
||||
(function autoAuth() {
|
||||
@@ -221,7 +251,9 @@ function apiFetch(path, options) {
|
||||
return fetch(path, opts).then((res) => {
|
||||
if (!res.ok) {
|
||||
return res.text().then(function(body) {
|
||||
throw new Error(body || (res.status + ' ' + res.statusText));
|
||||
const err = new Error(body || (res.status + ' ' + res.statusText));
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
@@ -327,6 +359,25 @@ function connectSSE() {
|
||||
eventSource.onopen = () => {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// Dismiss connection-lost banner and show reconnected flash
|
||||
if (_connectionLostTimer) {
|
||||
clearTimeout(_connectionLostTimer);
|
||||
_connectionLostTimer = null;
|
||||
}
|
||||
const lostBanner = document.getElementById('connection-banner');
|
||||
if (lostBanner) {
|
||||
const wasDisconnectedLong = _connectionLostAt && (Date.now() - _connectionLostAt > 10000);
|
||||
lostBanner.textContent = 'Reconnected';
|
||||
lostBanner.className = 'connection-banner connection-banner-success';
|
||||
setTimeout(() => { lostBanner.remove(); }, 2000);
|
||||
_connectionLostAt = null;
|
||||
// If disconnected >10s, reload chat history to catch missed messages
|
||||
if (wasDisconnectedLong && currentThreadId) {
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// If we were restarting, close the modal and reset button now that server is back
|
||||
if (isRestarting) {
|
||||
@@ -347,8 +398,28 @@ function connectSSE() {
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
_reconnectAttempts++;
|
||||
document.getElementById('sse-dot').classList.add('disconnected');
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
||||
|
||||
// Update existing banner with attempt count
|
||||
const existingBanner = document.getElementById('connection-banner');
|
||||
if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) {
|
||||
existingBanner.textContent = 'Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')';
|
||||
}
|
||||
|
||||
// Start connection-lost banner timer (3s delay)
|
||||
if (!_connectionLostTimer && !existingBanner) {
|
||||
_connectionLostAt = _connectionLostAt || Date.now();
|
||||
_connectionLostTimer = setTimeout(() => {
|
||||
_connectionLostTimer = null;
|
||||
// Only show if still disconnected
|
||||
const dot = document.getElementById('sse-dot');
|
||||
if (dot?.classList.contains('disconnected')) {
|
||||
showConnectionBanner('Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')', 'warning');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener('response', (e) => {
|
||||
@@ -360,6 +431,19 @@ function connectSSE() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Flush any remaining streaming buffer
|
||||
if (_streamDebounceTimer) {
|
||||
clearInterval(_streamDebounceTimer);
|
||||
_streamDebounceTimer = null;
|
||||
}
|
||||
if (_streamBuffer) {
|
||||
appendToLastAssistant(_streamBuffer);
|
||||
_streamBuffer = '';
|
||||
}
|
||||
// Remove streaming attribute from active assistant message
|
||||
const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]');
|
||||
if (streamingMsg) streamingMsg.removeAttribute('data-streaming');
|
||||
|
||||
finalizeActivityGroup();
|
||||
addMessage('assistant', data.content);
|
||||
enableChatInput();
|
||||
@@ -417,7 +501,31 @@ function connectSSE() {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
finalizeActivityGroup();
|
||||
appendToLastAssistant(data.content);
|
||||
|
||||
// Mark the active assistant message as streaming
|
||||
const container = document.getElementById('chat-messages');
|
||||
let lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
||||
if (!lastAssistant) {
|
||||
addMessage('assistant', '');
|
||||
lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
||||
}
|
||||
if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true');
|
||||
|
||||
// Accumulate chunks and debounce rendering at 50ms intervals
|
||||
_streamBuffer += data.content;
|
||||
// Force flush when buffer exceeds 10K chars to prevent memory buildup
|
||||
if (_streamBuffer.length > 10000) {
|
||||
appendToLastAssistant(_streamBuffer);
|
||||
_streamBuffer = '';
|
||||
}
|
||||
if (!_streamDebounceTimer) {
|
||||
_streamDebounceTimer = setInterval(() => {
|
||||
if (_streamBuffer) {
|
||||
appendToLastAssistant(_streamBuffer);
|
||||
_streamBuffer = '';
|
||||
}
|
||||
}, STREAM_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('status', (e) => {
|
||||
@@ -487,6 +595,22 @@ function connectSSE() {
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('turn_cost', (e) => {
|
||||
const event = JSON.parse(e.data);
|
||||
if (!isCurrentThread(event.thread_id)) return;
|
||||
// Add cost badge below last assistant message
|
||||
const messages = document.querySelectorAll('.message.assistant');
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const tokens = (event.input_tokens || 0) + (event.output_tokens || 0);
|
||||
if (lastMsg && tokens > 0) {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'turn-cost-badge';
|
||||
const cost = event.cost_usd ? ' \u00b7 ' + event.cost_usd : '';
|
||||
badge.textContent = tokens.toLocaleString() + ' tokens' + cost;
|
||||
lastMsg.appendChild(badge);
|
||||
}
|
||||
});
|
||||
|
||||
// Job event listeners (activity stream for all sandbox jobs)
|
||||
const jobEventTypes = [
|
||||
'job_message', 'job_tool_use', 'job_tool_result',
|
||||
@@ -578,6 +702,7 @@ function clearSuggestionChips() {
|
||||
|
||||
function sendMessage() {
|
||||
clearSuggestionChips();
|
||||
removeWelcomeCard();
|
||||
const input = document.getElementById('chat-input');
|
||||
if (authFlowPending) {
|
||||
showToast('Complete the auth step before sending chat messages.', 'info');
|
||||
@@ -589,10 +714,11 @@ function sendMessage() {
|
||||
console.warn('sendMessage: no thread selected, ignoring');
|
||||
return;
|
||||
}
|
||||
if (_sendCooldown) return;
|
||||
const content = input.value.trim();
|
||||
if (!content && stagedImages.length === 0) return;
|
||||
|
||||
addMessage('user', content || '(images attached)');
|
||||
const userMsg = addMessage('user', content || '(images attached)');
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
input.focus();
|
||||
@@ -608,7 +734,33 @@ function sendMessage() {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send: ' + err.message);
|
||||
// Handle rate limiting (429)
|
||||
if (err.status === 429) {
|
||||
showToast('Rate limited. Please wait.', 'error');
|
||||
_sendCooldown = true;
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
setTimeout(() => {
|
||||
_sendCooldown = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
// Keep the user message in DOM, add a retry link
|
||||
if (userMsg) {
|
||||
userMsg.classList.add('send-failed');
|
||||
userMsg.style.borderStyle = 'dashed';
|
||||
const retryLink = document.createElement('a');
|
||||
retryLink.className = 'retry-link';
|
||||
retryLink.href = '#';
|
||||
retryLink.textContent = 'Retry';
|
||||
retryLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg);
|
||||
input.value = content;
|
||||
sendMessage();
|
||||
});
|
||||
userMsg.appendChild(retryLink);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -887,11 +1039,36 @@ function copyMessage(btn) {
|
||||
});
|
||||
}
|
||||
|
||||
let _lastMessageDate = null;
|
||||
|
||||
function maybeInsertTimeSeparator(container, timestamp) {
|
||||
const date = timestamp ? new Date(timestamp) : new Date();
|
||||
const dateStr = date.toDateString();
|
||||
if (_lastMessageDate === dateStr) return;
|
||||
_lastMessageDate = dateStr;
|
||||
|
||||
const now = new Date();
|
||||
const today = now.toDateString();
|
||||
const yesterday = new Date(now.getTime() - 86400000).toDateString();
|
||||
|
||||
let label;
|
||||
if (dateStr === today) label = 'Today';
|
||||
else if (dateStr === yesterday) label = 'Yesterday';
|
||||
else label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'time-separator';
|
||||
sep.textContent = label;
|
||||
container.appendChild(sep);
|
||||
}
|
||||
|
||||
function addMessage(role, content) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
maybeInsertTimeSeparator(container);
|
||||
const div = createMessageElement(role, content);
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function appendToLastAssistant(chunk) {
|
||||
@@ -905,6 +1082,14 @@ function appendToLastAssistant(chunk) {
|
||||
const content = last.querySelector('.message-content');
|
||||
if (content) {
|
||||
content.innerHTML = renderMarkdown(raw);
|
||||
// Syntax highlighting for code blocks
|
||||
if (typeof hljs !== 'undefined') {
|
||||
requestAnimationFrame(() => {
|
||||
content.querySelectorAll('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
} else {
|
||||
@@ -992,16 +1177,14 @@ function addToolCard(name) {
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'activity-tool-body';
|
||||
body.style.display = 'none';
|
||||
|
||||
const output = document.createElement('pre');
|
||||
output.className = 'activity-tool-output';
|
||||
body.appendChild(output);
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = body.style.display !== 'none';
|
||||
body.style.display = isOpen ? 'none' : 'block';
|
||||
chevron.classList.toggle('expanded', !isOpen);
|
||||
body.classList.toggle('expanded');
|
||||
chevron.classList.toggle('expanded', body.classList.contains('expanded'));
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
@@ -1060,7 +1243,7 @@ function completeToolCard(name, success, error, parameters) {
|
||||
// Auto-expand so the error is immediately visible
|
||||
const body = entry.card.querySelector('.activity-tool-body');
|
||||
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
||||
if (body) body.style.display = 'block';
|
||||
if (body) body.classList.add('expanded');
|
||||
if (chevron) chevron.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
@@ -1547,6 +1730,13 @@ function loadHistory(before) {
|
||||
const isPaginating = !!before;
|
||||
if (isPaginating) loadingOlder = true;
|
||||
|
||||
// Show skeleton while loading (only for fresh loads)
|
||||
if (!isPaginating) {
|
||||
const chatContainer = document.getElementById('chat-messages');
|
||||
chatContainer.innerHTML = '';
|
||||
chatContainer.appendChild(renderSkeleton('message', 3));
|
||||
}
|
||||
|
||||
apiFetch(historyUrl).then((data) => {
|
||||
const container = document.getElementById('chat-messages');
|
||||
|
||||
@@ -1564,6 +1754,10 @@ function loadHistory(before) {
|
||||
addMessage('assistant', turn.response);
|
||||
}
|
||||
}
|
||||
// Show welcome card when history is empty
|
||||
if (data.turns.length === 0) {
|
||||
showWelcomeCard();
|
||||
}
|
||||
// Show processing indicator if the last turn is still in-progress
|
||||
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
||||
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
||||
@@ -1610,6 +1804,30 @@ function createMessageElement(role, content) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'message-timestamp';
|
||||
ts.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
div.appendChild(ts);
|
||||
|
||||
// Message content
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'message-content';
|
||||
if (role === 'user' || role === 'system') {
|
||||
contentEl.textContent = content;
|
||||
} else {
|
||||
div.setAttribute('data-raw', content);
|
||||
contentEl.innerHTML = renderMarkdown(content);
|
||||
// Syntax highlighting for code blocks
|
||||
if (typeof hljs !== 'undefined') {
|
||||
requestAnimationFrame(() => {
|
||||
contentEl.querySelectorAll('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
div.appendChild(contentEl);
|
||||
|
||||
if (role === 'assistant' || role === 'user') {
|
||||
div.classList.add('has-copy');
|
||||
div.setAttribute('data-copy-text', content);
|
||||
@@ -1625,15 +1843,6 @@ function createMessageElement(role, content) {
|
||||
div.appendChild(copyBtn);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'message-content';
|
||||
if (role === 'user' || role === 'system') {
|
||||
body.textContent = content;
|
||||
} else {
|
||||
div.setAttribute('data-raw', content);
|
||||
body.innerHTML = renderMarkdown(content);
|
||||
}
|
||||
div.appendChild(body);
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -1731,6 +1940,13 @@ function debouncedLoadThreads() {
|
||||
}
|
||||
|
||||
function loadThreads() {
|
||||
// Show skeleton while loading
|
||||
const threadListEl = document.getElementById('thread-list');
|
||||
if (threadListEl && threadListEl.children.length === 0) {
|
||||
threadListEl.innerHTML = '';
|
||||
threadListEl.appendChild(renderSkeleton('row', 4));
|
||||
}
|
||||
|
||||
apiFetch('/api/chat/threads').then((data) => {
|
||||
// Pinned assistant thread
|
||||
if (data.assistant_thread) {
|
||||
@@ -1828,6 +2044,11 @@ function switchToAssistant() {
|
||||
oldestTimestamp = null;
|
||||
loadHistory();
|
||||
loadThreads();
|
||||
if (window.innerWidth <= 768) {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
sidebar.classList.remove('expanded-mobile');
|
||||
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||
}
|
||||
}
|
||||
|
||||
function switchThread(threadId) {
|
||||
@@ -1839,12 +2060,18 @@ function switchThread(threadId) {
|
||||
oldestTimestamp = null;
|
||||
loadHistory();
|
||||
loadThreads();
|
||||
if (window.innerWidth <= 768) {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
sidebar.classList.remove('expanded-mobile');
|
||||
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||
}
|
||||
}
|
||||
|
||||
function createNewThread() {
|
||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||
currentThreadId = data.id || null;
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
showWelcomeCard();
|
||||
loadThreads();
|
||||
}).catch((err) => {
|
||||
showToast('Failed to create thread: ' + err.message, 'error');
|
||||
@@ -1853,9 +2080,17 @@ function createNewThread() {
|
||||
|
||||
function toggleThreadSidebar() {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
if (isMobile) {
|
||||
sidebar.classList.toggle('expanded-mobile');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
}
|
||||
const btn = document.getElementById('thread-toggle-btn');
|
||||
btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«';
|
||||
const isOpen = isMobile
|
||||
? sidebar.classList.contains('expanded-mobile')
|
||||
: !sidebar.classList.contains('collapsed');
|
||||
btn.innerHTML = isOpen ? '«' : '»';
|
||||
}
|
||||
|
||||
// Chat input auto-resize and keyboard handling
|
||||
@@ -1922,6 +2157,10 @@ chatInput.addEventListener('input', () => {
|
||||
ghost.style.display = 'block';
|
||||
wrapper.classList.add('has-ghost');
|
||||
}
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
if (sendBtn) {
|
||||
sendBtn.classList.toggle('active', chatInput.value.trim().length > 0);
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('blur', () => {
|
||||
// Small delay so mousedown on autocomplete item fires first
|
||||
@@ -1943,8 +2182,13 @@ document.getElementById('chat-messages').addEventListener('scroll', function ()
|
||||
});
|
||||
|
||||
function autoResizeTextarea(el) {
|
||||
const prev = el.offsetHeight;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
const target = Math.min(el.scrollHeight, 120);
|
||||
el.style.height = prev + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
el.style.height = target + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
@@ -1964,6 +2208,7 @@ function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-panel').forEach((p) => {
|
||||
p.classList.toggle('active', p.id === 'tab-' + tab);
|
||||
});
|
||||
applyAriaAttributes();
|
||||
|
||||
if (tab === 'memory') loadMemoryTree();
|
||||
if (tab === 'jobs') loadJobs();
|
||||
@@ -1974,8 +2219,26 @@ function switchTab(tab) {
|
||||
} else {
|
||||
stopPairingPoll();
|
||||
}
|
||||
updateTabIndicator();
|
||||
}
|
||||
|
||||
function updateTabIndicator() {
|
||||
const indicator = document.getElementById('tab-indicator');
|
||||
if (!indicator) return;
|
||||
const activeBtn = document.querySelector('.tab-bar button[data-tab].active');
|
||||
if (!activeBtn) {
|
||||
indicator.style.width = '0';
|
||||
return;
|
||||
}
|
||||
const bar = activeBtn.closest('.tab-bar');
|
||||
const barRect = bar.getBoundingClientRect();
|
||||
const btnRect = activeBtn.getBoundingClientRect();
|
||||
indicator.style.left = (btnRect.left - barRect.left) + 'px';
|
||||
indicator.style.width = btnRect.width + 'px';
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateTabIndicator);
|
||||
|
||||
// --- Memory (filesystem tree) ---
|
||||
|
||||
let memorySearchTimeout = null;
|
||||
@@ -4694,13 +4957,27 @@ document.addEventListener('keydown', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: close autocomplete, job detail, or blur input
|
||||
// Mod+/: toggle shortcuts overlay
|
||||
if (mod && e.key === '/') {
|
||||
e.preventDefault();
|
||||
toggleShortcutsOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: close modals, autocomplete, job detail, or blur input
|
||||
if (e.key === 'Escape') {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
if (acEl && acEl.style.display !== 'none') {
|
||||
hideSlashAutocomplete();
|
||||
return;
|
||||
}
|
||||
// Close shortcuts overlay if open
|
||||
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
||||
if (shortcutsOverlay?.style.display === 'flex') {
|
||||
shortcutsOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
closeModals();
|
||||
if (currentJobId) {
|
||||
closeJobDetail();
|
||||
} else if (inInput) {
|
||||
@@ -4732,9 +5009,17 @@ function switchSettingsSubtab(subtab) {
|
||||
searchInput.value = '';
|
||||
searchInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
// On mobile, drill into detail view
|
||||
if (window.innerWidth <= 768) {
|
||||
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
||||
}
|
||||
loadSettingsSubtab(subtab);
|
||||
}
|
||||
|
||||
function settingsBack() {
|
||||
document.querySelector('.settings-layout').classList.remove('settings-detail-active');
|
||||
}
|
||||
|
||||
function loadSettingsSubtab(subtab) {
|
||||
if (subtab === 'inference') loadInferenceSettings();
|
||||
else if (subtab === 'agent') loadAgentSettings();
|
||||
@@ -4870,6 +5155,19 @@ function renderCardsSkeleton(count) {
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSkeleton(type, count) {
|
||||
count = count || 3;
|
||||
var container = document.createElement('div');
|
||||
container.className = 'skeleton-container';
|
||||
for (var i = 0; i < count; i++) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'skeleton-' + type;
|
||||
el.innerHTML = '<div class="skeleton-bar shimmer"></div>';
|
||||
container.appendChild(el);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function loadInferenceSettings() {
|
||||
var container = document.getElementById('settings-inference-content');
|
||||
container.innerHTML = renderSettingsSkeleton(6);
|
||||
@@ -4888,11 +5186,13 @@ function loadInferenceSettings() {
|
||||
};
|
||||
// Inject available model IDs as suggestions for the selected_model field
|
||||
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
|
||||
var llmGroup = INFERENCE_SETTINGS[0];
|
||||
for (var i = 0; i < llmGroup.settings.length; i++) {
|
||||
if (llmGroup.settings[i].key === 'selected_model') {
|
||||
llmGroup.settings[i].suggestions = modelIds;
|
||||
break;
|
||||
if (modelIds.length > 0) {
|
||||
var llmGroup = INFERENCE_SETTINGS[0];
|
||||
for (var i = 0; i < llmGroup.settings.length; i++) {
|
||||
if (llmGroup.settings[i].key === 'selected_model') {
|
||||
llmGroup.settings[i].suggestions = modelIds;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
container.innerHTML = '';
|
||||
@@ -5020,34 +5320,30 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||
|
||||
if (def.type === 'boolean') {
|
||||
var boolSel = document.createElement('select');
|
||||
boolSel.className = 'settings-select';
|
||||
boolSel.setAttribute('data-setting-key', def.key);
|
||||
boolSel.setAttribute('aria-label', ariaLabel);
|
||||
var boolDefault = document.createElement('option');
|
||||
boolDefault.value = '';
|
||||
boolDefault.textContent = activeValue !== undefined && activeValue !== null
|
||||
? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014'
|
||||
: '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
|
||||
if (value === null || value === undefined) boolDefault.selected = true;
|
||||
boolSel.appendChild(boolDefault);
|
||||
var boolOn = document.createElement('option');
|
||||
boolOn.value = 'true';
|
||||
boolOn.textContent = I18n.t('settings.on');
|
||||
if (value === true) boolOn.selected = true;
|
||||
boolSel.appendChild(boolOn);
|
||||
var boolOff = document.createElement('option');
|
||||
boolOff.value = 'false';
|
||||
boolOff.textContent = I18n.t('settings.off');
|
||||
if (value === false) boolOff.selected = true;
|
||||
boolSel.appendChild(boolOff);
|
||||
boolSel.addEventListener('change', (function(k, el) {
|
||||
return function() {
|
||||
if (el.value === '') saveSetting(k, null);
|
||||
else saveSetting(k, el.value === 'true');
|
||||
};
|
||||
})(def.key, boolSel));
|
||||
inputWrap.appendChild(boolSel);
|
||||
var toggle = document.createElement('div');
|
||||
toggle.className = 'toggle-switch' + (value === 'true' || value === true ? ' on' : '');
|
||||
toggle.setAttribute('role', 'switch');
|
||||
toggle.setAttribute('aria-checked', value === 'true' || value === true ? 'true' : 'false');
|
||||
toggle.setAttribute('aria-label', ariaLabel);
|
||||
toggle.setAttribute('tabindex', '0');
|
||||
|
||||
var savedIndicator = document.createElement('span');
|
||||
savedIndicator.className = 'settings-saved-indicator';
|
||||
savedIndicator.textContent = I18n.t('settings.saved');
|
||||
|
||||
toggle.addEventListener('click', function() {
|
||||
var isOn = this.classList.toggle('on');
|
||||
this.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
||||
saveSetting(def.key, isOn ? 'true' : 'false', savedIndicator);
|
||||
});
|
||||
toggle.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.click();
|
||||
}
|
||||
});
|
||||
inputWrap.appendChild(toggle);
|
||||
inputWrap.appendChild(savedIndicator);
|
||||
} else if (def.type === 'select' && def.options) {
|
||||
var sel = document.createElement('select');
|
||||
sel.className = 'settings-select';
|
||||
@@ -5421,16 +5717,207 @@ function showToast(message, type) {
|
||||
const container = document.getElementById('toasts');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + (type || 'info');
|
||||
toast.textContent = message;
|
||||
|
||||
// Icon prefix
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'toast-icon';
|
||||
if (type === 'success') icon.textContent = '\u2713';
|
||||
else if (type === 'error') icon.textContent = '\u2717';
|
||||
else icon.textContent = '\u2139';
|
||||
toast.appendChild(icon);
|
||||
|
||||
// Message text
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
toast.appendChild(text);
|
||||
|
||||
// Countdown bar
|
||||
const countdown = document.createElement('div');
|
||||
countdown.className = 'toast-countdown';
|
||||
toast.appendChild(countdown);
|
||||
|
||||
container.appendChild(toast);
|
||||
// Trigger slide-in
|
||||
requestAnimationFrame(() => toast.classList.add('visible'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('visible');
|
||||
toast.addEventListener('transitionend', () => toast.remove());
|
||||
toast.classList.add('dismissing');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
// Fallback removal if transitionend doesn't fire
|
||||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// --- Welcome Card (Phase 4.2) ---
|
||||
|
||||
function showWelcomeCard() {
|
||||
const container = document.getElementById('chat-messages');
|
||||
if (!container || container.querySelector('.welcome-card')) return;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'welcome-card';
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.className = 'welcome-heading';
|
||||
heading.textContent = I18n.t('welcome.heading');
|
||||
card.appendChild(heading);
|
||||
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'welcome-description';
|
||||
desc.textContent = I18n.t('welcome.description');
|
||||
card.appendChild(desc);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'welcome-chips';
|
||||
|
||||
const suggestions = [
|
||||
{ key: 'welcome.runTool', fallback: 'Run a tool' },
|
||||
{ key: 'welcome.checkJobs', fallback: 'Check job status' },
|
||||
{ key: 'welcome.searchMemory', fallback: 'Search memory' },
|
||||
{ key: 'welcome.manageRoutines', fallback: 'Manage routines' },
|
||||
{ key: 'welcome.systemStatus', fallback: 'System status' },
|
||||
{ key: 'welcome.writeCode', fallback: 'Write code' },
|
||||
];
|
||||
suggestions.forEach(({ key, fallback }) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'welcome-chip';
|
||||
chip.textContent = I18n.t(key) || fallback;
|
||||
chip.addEventListener('click', () => sendSuggestion(chip));
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
|
||||
card.appendChild(chips);
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderEmptyState({ icon, title, hint, action }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'empty-state-card';
|
||||
|
||||
if (icon) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'empty-state-icon';
|
||||
iconEl.textContent = icon;
|
||||
wrapper.appendChild(iconEl);
|
||||
}
|
||||
|
||||
if (title) {
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'empty-state-title';
|
||||
titleEl.textContent = title;
|
||||
wrapper.appendChild(titleEl);
|
||||
}
|
||||
|
||||
if (hint) {
|
||||
const hintEl = document.createElement('div');
|
||||
hintEl.className = 'empty-state-hint';
|
||||
hintEl.textContent = hint;
|
||||
wrapper.appendChild(hintEl);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'empty-state-action';
|
||||
btn.textContent = action.label || 'Go';
|
||||
if (action.onClick) btn.addEventListener('click', action.onClick);
|
||||
wrapper.appendChild(btn);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function sendSuggestion(btn) {
|
||||
const textarea = document.getElementById('chat-input');
|
||||
if (textarea) {
|
||||
textarea.value = btn.textContent;
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function removeWelcomeCard() {
|
||||
const card = document.querySelector('.welcome-card');
|
||||
if (card) card.remove();
|
||||
}
|
||||
|
||||
// --- Connection Status Banner (Phase 4.1) ---
|
||||
|
||||
function showConnectionBanner(message, type) {
|
||||
const existing = document.getElementById('connection-banner');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'connection-banner';
|
||||
banner.className = 'connection-banner connection-banner-' + type;
|
||||
banner.textContent = message;
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
|
||||
// --- Keyboard Shortcut Helpers (Phase 7.4) ---
|
||||
|
||||
function focusMemorySearch() {
|
||||
const memSearch = document.getElementById('memory-search');
|
||||
if (memSearch) {
|
||||
if (currentTab !== 'memory') switchTab('memory');
|
||||
memSearch.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShortcutsOverlay() {
|
||||
let overlay = document.getElementById('shortcuts-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'shortcuts-overlay';
|
||||
overlay.className = 'shortcuts-overlay';
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML =
|
||||
'<div class="shortcuts-content">'
|
||||
+ '<h3>Keyboard Shortcuts</h3>'
|
||||
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + 1-5</kbd> Switch tabs</div>'
|
||||
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + N</kbd> New thread</div>'
|
||||
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + K</kbd> Focus search/input</div>'
|
||||
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + /</kbd> Toggle this overlay</div>'
|
||||
+ '<div class="shortcut-row"><kbd>Escape</kbd> Close modals</div>'
|
||||
+ '<button class="shortcuts-close">Close</button>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('.shortcuts-close').addEventListener('click', () => {
|
||||
overlay.style.display = 'none';
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.style.display = 'none';
|
||||
});
|
||||
}
|
||||
overlay.style.display = overlay.style.display === 'flex' ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
function closeModals() {
|
||||
// Close shortcuts overlay
|
||||
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
||||
if (shortcutsOverlay) shortcutsOverlay.style.display = 'none';
|
||||
|
||||
// Close restart confirmation modal
|
||||
const restartModal = document.getElementById('restart-confirm-modal');
|
||||
if (restartModal) restartModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- ARIA Accessibility (Phase 5.2) ---
|
||||
|
||||
function applyAriaAttributes() {
|
||||
const tabBar = document.querySelector('.tab-bar');
|
||||
if (tabBar) tabBar.setAttribute('role', 'tablist');
|
||||
|
||||
document.querySelectorAll('.tab-bar button[data-tab]').forEach(btn => {
|
||||
btn.setAttribute('role', 'tab');
|
||||
btn.setAttribute('aria-selected', btn.classList.contains('active') ? 'true' : 'false');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.setAttribute('role', 'tabpanel');
|
||||
panel.setAttribute('aria-hidden', panel.classList.contains('active') ? 'false' : 'true');
|
||||
});
|
||||
}
|
||||
|
||||
// Apply ARIA attributes on initial load
|
||||
applyAriaAttributes();
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -5469,6 +5956,17 @@ document.getElementById('skill-search-btn').addEventListener('click', () => sear
|
||||
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
||||
document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
|
||||
document.getElementById('settings-back-btn')?.addEventListener('click', () => settingsBack());
|
||||
|
||||
// --- Mobile: close thread sidebar on outside click ---
|
||||
document.addEventListener('click', function(e) {
|
||||
const sidebar = document.getElementById('thread-sidebar');
|
||||
if (sidebar && sidebar.classList.contains('expanded-mobile') &&
|
||||
!sidebar.contains(e.target)) {
|
||||
sidebar.classList.remove('expanded-mobile');
|
||||
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
||||
}
|
||||
});
|
||||
|
||||
// --- Delegated Event Handlers (for dynamically generated HTML) ---
|
||||
|
||||
|
||||
@@ -521,4 +521,29 @@ I18n.register('en', {
|
||||
'channels.replDesc': 'Simple read-eval-print loop for testing',
|
||||
'channels.configureVia': 'Configure via {env}',
|
||||
'channels.runWith': 'Run with: {cmd}',
|
||||
|
||||
// Welcome Card
|
||||
'welcome.heading': 'What can I help you with?',
|
||||
'welcome.description': 'IronClaw is your secure AI assistant. Choose a suggestion below or type your own message.',
|
||||
'welcome.runTool': 'Run a tool',
|
||||
'welcome.checkJobs': 'Check job status',
|
||||
'welcome.searchMemory': 'Search memory',
|
||||
'welcome.manageRoutines': 'Manage routines',
|
||||
'welcome.systemStatus': 'System status',
|
||||
'welcome.writeCode': 'Write code',
|
||||
|
||||
// Connection
|
||||
'connection.disconnected': 'Disconnected — attempting to reconnect',
|
||||
'connection.reconnecting': 'Reconnecting (attempt {count})...',
|
||||
'connection.reconnected': 'Reconnected',
|
||||
|
||||
// Messages
|
||||
'message.you': 'You',
|
||||
'message.assistant': 'IronClaw',
|
||||
'message.system': 'System',
|
||||
'message.copy': 'Copy',
|
||||
'message.copied': 'Copied!',
|
||||
|
||||
// Approval
|
||||
'approval.pressY': 'Press Y to approve, N to deny',
|
||||
});
|
||||
|
||||
@@ -520,4 +520,29 @@ I18n.register('zh-CN', {
|
||||
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
|
||||
'channels.configureVia': '通过 {env} 配置',
|
||||
'channels.runWith': '运行命令: {cmd}',
|
||||
|
||||
// Welcome Card
|
||||
'welcome.heading': '有什么可以帮助您的?',
|
||||
'welcome.description': 'IronClaw 是您的安全 AI 助手。选择下方的建议或输入您自己的消息。',
|
||||
'welcome.runTool': '运行工具',
|
||||
'welcome.checkJobs': '查看任务状态',
|
||||
'welcome.searchMemory': '搜索记忆',
|
||||
'welcome.manageRoutines': '管理例程',
|
||||
'welcome.systemStatus': '系统状态',
|
||||
'welcome.writeCode': '编写代码',
|
||||
|
||||
// Connection
|
||||
'connection.disconnected': '已断开连接 — 正在尝试重新连接',
|
||||
'connection.reconnecting': '正在重新连接(第 {count} 次尝试)...',
|
||||
'connection.reconnected': '已重新连接',
|
||||
|
||||
// Messages
|
||||
'message.you': '你',
|
||||
'message.assistant': 'IronClaw',
|
||||
'message.system': '系统',
|
||||
'message.copy': '复制',
|
||||
'message.copied': '已复制!',
|
||||
|
||||
// Approval
|
||||
'approval.pressY': '按 Y 批准,N 拒绝',
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
<div id="app">
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<div class="tab-indicator" id="tab-indicator"></div>
|
||||
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
@@ -292,9 +293,11 @@
|
||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
||||
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-toolbar">
|
||||
<button id="settings-back-btn" class="settings-back-btn">← Back</button>
|
||||
<div class="settings-search">
|
||||
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
|
||||
</div>
|
||||
|
||||
+868
-240
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,16 @@ pub enum SseEvent {
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Per-turn token usage and cost summary.
|
||||
#[serde(rename = "turn_cost")]
|
||||
TurnCost {
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cost_usd: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Extension activation status change (WASM channels).
|
||||
#[serde(rename = "extension_status")]
|
||||
ExtensionStatus {
|
||||
@@ -797,6 +807,7 @@ impl WsServerMessage {
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::TurnCost { .. } => "turn_cost",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
@@ -68,7 +68,7 @@ impl WebhookServer {
|
||||
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
|
||||
})?;
|
||||
|
||||
tracing::info!("Webhook server listening on {}", self.config.addr);
|
||||
tracing::debug!("Webhook server listening on {}", self.config.addr);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
@@ -129,7 +129,7 @@ impl WebhookServer {
|
||||
});
|
||||
self.handle = Some(handle);
|
||||
|
||||
tracing::info!("Webhook server listening on {}", new_addr);
|
||||
tracing::debug!("Webhook server listening on {}", new_addr);
|
||||
|
||||
(old_shutdown_tx, old_handle)
|
||||
}
|
||||
|
||||
+44
-9
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
//! Shared terminal design system.
|
||||
//!
|
||||
//! Centralizes color tokens, rendering primitives, and width detection
|
||||
//! for consistent CLI output. Respects `NO_COLOR` env var and non-TTY
|
||||
//! output (piping to file, CI, etc.).
|
||||
|
||||
use std::io::IsTerminal;
|
||||
|
||||
// ── Color detection ─────────────────────────────────────────
|
||||
|
||||
/// Returns `true` when ANSI colors should be emitted.
|
||||
///
|
||||
/// Disabled when:
|
||||
/// - `NO_COLOR` env var is set (any value — per <https://no-color.org/>)
|
||||
/// - 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!(
|
||||
" {}{:<width$}{} {}{}{}",
|
||||
dim(),
|
||||
key,
|
||||
reset(),
|
||||
accent(),
|
||||
value,
|
||||
reset(),
|
||||
width = key_width,
|
||||
)
|
||||
}
|
||||
|
||||
/// Status icon for check results.
|
||||
///
|
||||
/// - `pass` → green `✓`
|
||||
/// - `fail` → red `✗`
|
||||
/// - `skip` → dim `○`
|
||||
pub fn status_icon(kind: StatusKind) -> String {
|
||||
match kind {
|
||||
StatusKind::Pass => format!("{}\u{2713}{}", success(), reset()),
|
||||
StatusKind::Fail => format!("{}\u{2717}{}", error(), reset()),
|
||||
StatusKind::Skip => format!("{}\u{25CB}{}", dim(), reset()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of status check result.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusKind {
|
||||
Pass,
|
||||
Fail,
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Top border of a box with an optional label.
|
||||
///
|
||||
/// ```text
|
||||
/// ┌─ label ──────────────────┐
|
||||
/// ```
|
||||
pub fn box_top(label: &str, width: usize) -> String {
|
||||
if label.is_empty() {
|
||||
let fill = width.saturating_sub(2);
|
||||
return format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(fill));
|
||||
}
|
||||
let label_part = format!(" {} ", label);
|
||||
// ┌ (1) + ─ (1) + label_part + fill + ┐ (1) = width
|
||||
let fill = width.saturating_sub(label_part.len() + 3);
|
||||
format!(
|
||||
"\u{250C}\u{2500}{}{}{}\u{2510}",
|
||||
bold(),
|
||||
label_part,
|
||||
reset(),
|
||||
)
|
||||
.replace("\u{2510}", &format!("{}\u{2510}", "\u{2500}".repeat(fill)))
|
||||
}
|
||||
|
||||
/// Content line inside a box.
|
||||
///
|
||||
/// ```text
|
||||
/// │ content │
|
||||
/// ```
|
||||
pub fn box_line(content: &str, width: usize) -> String {
|
||||
let inner = width.saturating_sub(4); // │ + space + space + │
|
||||
let padded = if content.len() >= inner {
|
||||
content.to_string()
|
||||
} else {
|
||||
format!("{}{}", content, " ".repeat(inner - content.len()))
|
||||
};
|
||||
format!("\u{2502} {} \u{2502}", padded)
|
||||
}
|
||||
|
||||
/// Bottom border of a box.
|
||||
///
|
||||
/// ```text
|
||||
/// └──────────────────────────┘
|
||||
/// ```
|
||||
pub fn box_bottom(width: usize) -> String {
|
||||
let fill = width.saturating_sub(2);
|
||||
format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(fill))
|
||||
}
|
||||
|
||||
/// Format a check result line for doctor/status commands.
|
||||
///
|
||||
/// ```text
|
||||
/// ✓ Database libsql (connected)
|
||||
/// ✗ Docker not running — start with: open -a Docker
|
||||
/// ○ Embeddings disabled
|
||||
/// ```
|
||||
pub fn check_line(kind: StatusKind, name: &str, detail: &str, name_width: usize) -> String {
|
||||
format!(
|
||||
" {} {:<width$} {}",
|
||||
status_icon(kind),
|
||||
name,
|
||||
detail,
|
||||
width = name_width,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separator_produces_correct_width() {
|
||||
// In test environment NO_COLOR or non-TTY may be active,
|
||||
// so strip ANSI to count visible characters.
|
||||
let s = separator(10);
|
||||
let visible: String = strip_ansi(&s);
|
||||
assert_eq!(visible.chars().count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_line_contains_key_and_value() {
|
||||
let line = kv_line("model", "gpt-4o", 12);
|
||||
let visible = strip_ansi(&line);
|
||||
assert!(visible.contains("model"));
|
||||
assert!(visible.contains("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_icon_all_kinds() {
|
||||
// Just verify no panic for each variant
|
||||
let _ = status_icon(StatusKind::Pass);
|
||||
let _ = status_icon(StatusKind::Fail);
|
||||
let _ = status_icon(StatusKind::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_drawing() {
|
||||
let top = box_top("test", 30);
|
||||
let line = box_line("content", 30);
|
||||
let bottom = box_bottom(30);
|
||||
|
||||
assert!(top.contains('\u{250C}')); // ┌
|
||||
assert!(line.contains('\u{2502}')); // │
|
||||
assert!(bottom.contains('\u{2514}')); // └
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_line_formatting() {
|
||||
let line = check_line(StatusKind::Pass, "Database", "connected", 18);
|
||||
let visible = strip_ansi(&line);
|
||||
assert!(visible.contains("Database"));
|
||||
assert!(visible.contains("connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_width_in_range() {
|
||||
let w = term_width();
|
||||
assert!(w >= 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
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -18,6 +18,7 @@ mod channels;
|
||||
mod completion;
|
||||
mod config;
|
||||
mod doctor;
|
||||
pub mod fmt;
|
||||
mod hooks;
|
||||
#[cfg(feature = "import")]
|
||||
pub mod import;
|
||||
@@ -111,16 +112,20 @@ pub enum Command {
|
||||
skip_auth: bool,
|
||||
|
||||
/// Reconfigure channels only
|
||||
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
|
||||
#[arg(long, conflicts_with_all = ["provider_only", "quick", "step"], help = "Deprecated: use --step channels")]
|
||||
channels_only: bool,
|
||||
|
||||
/// Reconfigure LLM provider and model only
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "quick", "step"], help = "Deprecated: use --step provider")]
|
||||
provider_only: bool,
|
||||
|
||||
/// Quick setup: auto-defaults everything except LLM provider and model
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "provider_only", "step"])]
|
||||
quick: bool,
|
||||
|
||||
/// Run only specific setup steps (comma-separated: provider, channels, model, database, security)
|
||||
#[arg(long, value_delimiter = ',', conflicts_with_all = ["channels_only", "provider_only", "quick"])]
|
||||
step: Vec<String>,
|
||||
},
|
||||
|
||||
/// Manage configuration settings
|
||||
|
||||
+57
-48
@@ -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(())
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
|
||||
.unwrap_or(false);
|
||||
|
||||
if !enabled {
|
||||
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
|
||||
tracing::debug!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ pub async fn connect_with_handles(
|
||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||
};
|
||||
backend.run_migrations().await?;
|
||||
tracing::info!("libSQL database connected and migrations applied");
|
||||
tracing::debug!("libSQL database connected and migrations applied");
|
||||
|
||||
handles.libsql_db = Some(backend.shared_db());
|
||||
|
||||
|
||||
+47
-3
@@ -38,10 +38,49 @@ fn main() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
ironclaw::bootstrap::load_ironclaw_env();
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
let result = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async_main())
|
||||
.block_on(async_main());
|
||||
|
||||
if let Err(ref e) = result {
|
||||
format_top_level_error(e);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Format a top-level error with color and recovery hints.
|
||||
fn format_top_level_error(err: &anyhow::Error) {
|
||||
use ironclaw::cli::fmt;
|
||||
let msg = format!("{err:#}");
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" {}\u{2717}{} {}", fmt::error(), fmt::reset(), msg);
|
||||
|
||||
// Provide recovery hints for common errors
|
||||
let lower = msg.to_ascii_lowercase();
|
||||
let hint = if lower.contains("database_url")
|
||||
|| lower.contains("database") && lower.contains("not set")
|
||||
{
|
||||
Some("run `ironclaw onboard` or set DATABASE_URL in .env")
|
||||
} else if lower.contains("connection refused") || lower.contains("connect error") {
|
||||
Some("check that the database server is running")
|
||||
} else if lower.contains("session") && lower.contains("not found") {
|
||||
Some("run `ironclaw onboard` to set up authentication")
|
||||
} else if lower.contains("secrets_master_key") {
|
||||
Some("run `ironclaw onboard` or set SECRETS_MASTER_KEY in .env")
|
||||
} else if lower.contains("already running") {
|
||||
Some("stop the other instance or remove the stale PID file")
|
||||
} else if lower.contains("onboard") {
|
||||
Some("run `ironclaw onboard` to complete setup")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(hint_text) = hint {
|
||||
eprintln!(" {}hint:{} {}", fmt::dim(), fmt::reset(), hint_text,);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
async fn async_main() -> anyhow::Result<()> {
|
||||
@@ -190,6 +229,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
channels_only,
|
||||
provider_only,
|
||||
quick,
|
||||
step,
|
||||
}) => {
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
{
|
||||
@@ -198,6 +238,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
channels_only: *channels_only,
|
||||
provider_only: *provider_only,
|
||||
quick: *quick,
|
||||
steps: step.clone(),
|
||||
};
|
||||
let mut wizard =
|
||||
SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?;
|
||||
@@ -205,7 +246,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
let _ = (skip_auth, channels_only, provider_only, quick);
|
||||
let _ = (skip_auth, channels_only, provider_only, quick, step);
|
||||
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
||||
}
|
||||
return Ok(());
|
||||
@@ -233,6 +274,8 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
|
||||
// ── Agent startup ──────────────────────────────────────────────────
|
||||
|
||||
// Enhanced first-run detection
|
||||
@@ -691,6 +734,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
.and_then(|t| t.public_url())
|
||||
.or_else(|| config.tunnel.public_url.clone()),
|
||||
tunnel_provider: active_tunnel.as_ref().map(|t| t.name().to_string()),
|
||||
startup_elapsed: Some(startup_start.elapsed()),
|
||||
};
|
||||
ironclaw::boot_screen::print_boot_screen(&boot_info);
|
||||
}
|
||||
|
||||
+48
-23
@@ -123,15 +123,32 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
|
||||
writeln!(stdout, "\r")?;
|
||||
|
||||
for (i, (label, _)) in options.iter().enumerate() {
|
||||
let checkbox = if selected[i] { "[x]" } else { "[ ]" };
|
||||
let prefix = if i == cursor_pos { ">" } else { " " };
|
||||
|
||||
if i == cursor_pos {
|
||||
// Cursor line: cyan cursor, then colored checkbox
|
||||
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||||
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
|
||||
write!(stdout, " \u{25b8} ")?;
|
||||
if selected[i] {
|
||||
execute!(stdout, SetForegroundColor(Color::Green))?;
|
||||
write!(stdout, "[\u{2713}]")?;
|
||||
} else {
|
||||
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
|
||||
write!(stdout, "[\u{00b7}]")?;
|
||||
}
|
||||
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||||
writeln!(stdout, " {}\r", label)?;
|
||||
execute!(stdout, ResetColor)?;
|
||||
} else {
|
||||
writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?;
|
||||
write!(stdout, " ")?;
|
||||
if selected[i] {
|
||||
execute!(stdout, SetForegroundColor(Color::Green))?;
|
||||
write!(stdout, "[\u{2713}]")?;
|
||||
execute!(stdout, ResetColor)?;
|
||||
} else {
|
||||
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
|
||||
write!(stdout, "[\u{00b7}]")?;
|
||||
execute!(stdout, ResetColor)?;
|
||||
}
|
||||
writeln!(stdout, " {}\r", label)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,18 +301,12 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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!();
|
||||
}
|
||||
|
||||
|
||||
+329
-127
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Interactive setup wizard for IronClaw.
|
||||
@@ -188,6 +190,55 @@ impl SetupWizard {
|
||||
print_banner();
|
||||
print_header("IronClaw Setup Wizard");
|
||||
|
||||
if !self.config.steps.is_empty() {
|
||||
// Selective step mode: reconnect to existing DB and load settings,
|
||||
// then run only the requested steps.
|
||||
self.reconnect_existing_db().await?;
|
||||
|
||||
let valid_steps = ["provider", "channels", "model", "database", "security"];
|
||||
for s in &self.config.steps {
|
||||
if !valid_steps.contains(&s.as_str()) {
|
||||
return Err(SetupError::Config(format!(
|
||||
"Unknown step '{}'. Valid steps: {}",
|
||||
s,
|
||||
valid_steps.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let total = self.config.steps.len();
|
||||
for (i, step_name) in self.config.steps.clone().iter().enumerate() {
|
||||
let step_num = i + 1;
|
||||
match step_name.as_str() {
|
||||
"database" => {
|
||||
print_step(step_num, total, "Database Connection");
|
||||
self.step_database().await?;
|
||||
}
|
||||
"security" => {
|
||||
print_step(step_num, total, "Security");
|
||||
self.step_security().await?;
|
||||
}
|
||||
"provider" => {
|
||||
print_step(step_num, total, "Inference Provider");
|
||||
self.step_inference_provider().await?;
|
||||
}
|
||||
"model" => {
|
||||
print_step(step_num, total, "Model Selection");
|
||||
self.step_model_selection().await?;
|
||||
}
|
||||
"channels" => {
|
||||
print_step(step_num, total, "Channel Configuration");
|
||||
self.step_channels().await?;
|
||||
}
|
||||
_ => {} // already validated above
|
||||
}
|
||||
self.persist_after_step().await;
|
||||
}
|
||||
|
||||
self.save_and_summarize().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.config.channels_only {
|
||||
// Channels-only mode: reconnect to existing DB and load settings
|
||||
// before running the channel step, so secrets and save work.
|
||||
@@ -220,23 +271,23 @@ impl SetupWizard {
|
||||
// Pre-populate backend from env so step_inference_provider
|
||||
// can offer "Keep current provider?" instead of asking from scratch.
|
||||
if self.settings.llm_backend.is_none() {
|
||||
use crate::config::helpers::env_or_override;
|
||||
if let Some(b) = env_or_override("LLM_BACKEND")
|
||||
&& !b.trim().is_empty()
|
||||
{
|
||||
self.settings.llm_backend = Some(b.trim().to_string());
|
||||
} else if env_or_override("NEARAI_API_KEY").is_some() {
|
||||
if let Ok(b) = std::env::var("LLM_BACKEND") {
|
||||
self.settings.llm_backend = Some(b);
|
||||
} else if std::env::var("NEARAI_API_KEY").is_ok() {
|
||||
self.settings.llm_backend = Some("nearai".to_string());
|
||||
} else if env_or_override("ANTHROPIC_API_KEY").is_some()
|
||||
|| env_or_override("ANTHROPIC_OAUTH_TOKEN").is_some()
|
||||
} else if std::env::var("ANTHROPIC_API_KEY").is_ok()
|
||||
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok()
|
||||
{
|
||||
self.settings.llm_backend = Some("anthropic".to_string());
|
||||
} else if env_or_override("OPENAI_API_KEY").is_some() {
|
||||
} else if std::env::var("OPENAI_API_KEY").is_ok() {
|
||||
self.settings.llm_backend = Some("openai".to_string());
|
||||
} else if std::env::var("OPENROUTER_API_KEY").is_ok() {
|
||||
self.settings.llm_backend = Some("openrouter".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY")
|
||||
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
|
||||
&& !api_key.is_empty()
|
||||
&& self.settings.llm_backend.as_deref() == Some("nearai")
|
||||
{
|
||||
// NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts
|
||||
@@ -254,6 +305,79 @@ impl SetupWizard {
|
||||
print_info(&format!("Using default model: {default}"));
|
||||
}
|
||||
self.persist_after_step().await;
|
||||
} else if self.settings.llm_backend.as_deref() == Some("anthropic")
|
||||
&& let Some(api_key) = Self::detect_anthropic_key()
|
||||
{
|
||||
// Anthropic key detected — skip interactive prompts
|
||||
print_info("Anthropic credentials found — using Anthropic provider");
|
||||
let secret_name = if api_key.starts_with("sk-ant-oat") {
|
||||
"llm_anthropic_oauth_token"
|
||||
} else {
|
||||
"llm_anthropic_api_key"
|
||||
};
|
||||
if let Ok(ctx) = self.init_secrets_context().await {
|
||||
let key = SecretString::from(api_key.clone());
|
||||
if let Err(e) = ctx.save_secret(secret_name, &key).await {
|
||||
tracing::warn!("Failed to persist Anthropic key to secrets: {}", e);
|
||||
}
|
||||
}
|
||||
self.llm_api_key = Some(SecretString::from(api_key));
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
if self.settings.selected_model.is_none() {
|
||||
let default = registry
|
||||
.find("anthropic")
|
||||
.map(|d| d.default_model.as_str())
|
||||
.unwrap_or("claude-sonnet-4-20250514");
|
||||
self.settings.selected_model = Some(default.to_string());
|
||||
print_info(&format!("Using default model: {default}"));
|
||||
}
|
||||
self.persist_after_step().await;
|
||||
} else if let Ok(api_key) = std::env::var("OPENAI_API_KEY")
|
||||
&& !api_key.is_empty()
|
||||
&& self.settings.llm_backend.as_deref() == Some("openai")
|
||||
{
|
||||
// OpenAI key detected — skip interactive prompts
|
||||
print_info("OPENAI_API_KEY found — using OpenAI provider");
|
||||
if let Ok(ctx) = self.init_secrets_context().await {
|
||||
let key = SecretString::from(api_key.clone());
|
||||
if let Err(e) = ctx.save_secret("llm_openai_api_key", &key).await {
|
||||
tracing::warn!("Failed to persist OPENAI_API_KEY to secrets: {}", e);
|
||||
}
|
||||
}
|
||||
self.llm_api_key = Some(SecretString::from(api_key));
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
if self.settings.selected_model.is_none() {
|
||||
let default = registry
|
||||
.find("openai")
|
||||
.map(|d| d.default_model.as_str())
|
||||
.unwrap_or("gpt-5-mini");
|
||||
self.settings.selected_model = Some(default.to_string());
|
||||
print_info(&format!("Using default model: {default}"));
|
||||
}
|
||||
self.persist_after_step().await;
|
||||
} else if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY")
|
||||
&& !api_key.is_empty()
|
||||
&& self.settings.llm_backend.as_deref() == Some("openrouter")
|
||||
{
|
||||
// OpenRouter key detected — skip interactive prompts
|
||||
print_info("OPENROUTER_API_KEY found — using OpenRouter provider");
|
||||
if let Ok(ctx) = self.init_secrets_context().await {
|
||||
let key = SecretString::from(api_key.clone());
|
||||
if let Err(e) = ctx.save_secret("llm_openrouter_api_key", &key).await {
|
||||
tracing::warn!("Failed to persist OPENROUTER_API_KEY to secrets: {}", e);
|
||||
}
|
||||
}
|
||||
self.llm_api_key = Some(SecretString::from(api_key));
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
if self.settings.selected_model.is_none() {
|
||||
let default = registry
|
||||
.find("openrouter")
|
||||
.map(|d| d.default_model.as_str())
|
||||
.unwrap_or("openai/gpt-4o");
|
||||
self.settings.selected_model = Some(default.to_string());
|
||||
print_info(&format!("Using default model: {default}"));
|
||||
}
|
||||
self.persist_after_step().await;
|
||||
} else {
|
||||
print_step(1, 2, "Inference Provider");
|
||||
self.step_inference_provider().await?;
|
||||
@@ -1132,33 +1256,98 @@ impl SetupWizard {
|
||||
|
||||
// Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock
|
||||
let selectable = registry.selectable();
|
||||
let mut options: Vec<String> = Vec::with_capacity(3 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(3 + selectable.len());
|
||||
|
||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||
provider_ids.push("nearai".to_string());
|
||||
options.push("Gemini CLI - Official Gemini API via Gemini CLI OAuth".to_string());
|
||||
provider_ids.push("gemini_oauth".to_string());
|
||||
// Detect which providers have API keys already set in the environment.
|
||||
let detected_env: HashMap<&str, bool> = [
|
||||
("nearai", std::env::var("NEARAI_API_KEY").is_ok()),
|
||||
(
|
||||
"anthropic",
|
||||
std::env::var("ANTHROPIC_API_KEY").is_ok()
|
||||
|| std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok(),
|
||||
),
|
||||
("openai", std::env::var("OPENAI_API_KEY").is_ok()),
|
||||
("openrouter", std::env::var("OPENROUTER_API_KEY").is_ok()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
|
||||
provider_ids.push("openai_codex".to_string());
|
||||
// Helper: build a label for a provider entry, prepending a checkmark if detected.
|
||||
let make_label = |id: &str, name: &str, desc: &str| -> String {
|
||||
if detected_env.get(id).copied().unwrap_or(false) {
|
||||
format!("\u{2713} {:<15}- {}", name, desc)
|
||||
} else {
|
||||
format!(" {:<15}- {}", name, desc)
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all entries as (provider_id, label, is_detected).
|
||||
struct ProviderEntry {
|
||||
id: String,
|
||||
label: String,
|
||||
detected: bool,
|
||||
}
|
||||
|
||||
let mut entries: Vec<ProviderEntry> = Vec::with_capacity(2 + selectable.len());
|
||||
|
||||
entries.push(ProviderEntry {
|
||||
id: "nearai".to_string(),
|
||||
label: make_label("nearai", "NEAR AI", "multi-model access via NEAR account"),
|
||||
detected: detected_env.get("nearai").copied().unwrap_or(false),
|
||||
});
|
||||
|
||||
entries.push(ProviderEntry {
|
||||
id: "gemini_oauth".to_string(),
|
||||
label: make_label(
|
||||
"gemini_oauth",
|
||||
"Gemini CLI",
|
||||
"Official Gemini API via Gemini CLI OAuth",
|
||||
),
|
||||
detected: false,
|
||||
});
|
||||
|
||||
entries.push(ProviderEntry {
|
||||
id: "openai_codex".to_string(),
|
||||
label: make_label(
|
||||
"openai_codex",
|
||||
"OpenAI Codex",
|
||||
"ChatGPT subscription (Plus/Pro/Max)",
|
||||
),
|
||||
detected: false,
|
||||
});
|
||||
|
||||
for def in &selectable {
|
||||
let label = format!(
|
||||
"{:<17}- {}",
|
||||
def.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name())
|
||||
.unwrap_or(&def.id),
|
||||
def.description
|
||||
);
|
||||
options.push(label);
|
||||
provider_ids.push(def.id.clone());
|
||||
let display_name = def
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name())
|
||||
.unwrap_or(&def.id);
|
||||
entries.push(ProviderEntry {
|
||||
id: def.id.clone(),
|
||||
label: make_label(&def.id, display_name, &def.description),
|
||||
detected: detected_env.get(def.id.as_str()).copied().unwrap_or(false),
|
||||
});
|
||||
}
|
||||
|
||||
// Bedrock is a special case (native AWS SDK, not registry-based)
|
||||
options.push("AWS Bedrock - Claude & other models via AWS (IAM, SSO)".to_string());
|
||||
provider_ids.push("bedrock".to_string());
|
||||
entries.push(ProviderEntry {
|
||||
id: "bedrock".to_string(),
|
||||
label: make_label(
|
||||
"bedrock",
|
||||
"AWS Bedrock",
|
||||
"Claude & other models via AWS (IAM, SSO)",
|
||||
),
|
||||
detected: false,
|
||||
});
|
||||
|
||||
// Sort: detected providers first, preserving relative order within each group.
|
||||
entries.sort_by_key(|e| !e.detected);
|
||||
|
||||
let mut options: Vec<String> = Vec::with_capacity(entries.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(entries.len());
|
||||
for entry in &entries {
|
||||
options.push(entry.label.clone());
|
||||
provider_ids.push(entry.id.clone());
|
||||
}
|
||||
|
||||
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
|
||||
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
|
||||
@@ -1262,6 +1451,24 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect an Anthropic credential from the environment.
|
||||
///
|
||||
/// Checks `ANTHROPIC_API_KEY` first, then `ANTHROPIC_OAUTH_TOKEN`.
|
||||
/// Returns the key/token string if found, or `None`.
|
||||
fn detect_anthropic_key() -> Option<String> {
|
||||
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
|
||||
&& !key.is_empty()
|
||||
{
|
||||
return Some(key);
|
||||
}
|
||||
if let Ok(token) = std::env::var("ANTHROPIC_OAUTH_TOKEN")
|
||||
&& !token.is_empty()
|
||||
{
|
||||
return Some(token);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Update the selected LLM backend while preserving the current model when
|
||||
/// the backend did not actually change.
|
||||
fn set_llm_backend_preserving_model(&mut self, backend: &str) {
|
||||
@@ -3079,8 +3286,11 @@ impl SetupWizard {
|
||||
let _ = loaded;
|
||||
}
|
||||
|
||||
/// Save settings to the database and `~/.ironclaw/.env`, then print summary.
|
||||
/// Save settings to the database and `~/.ironclaw/.env`, then print
|
||||
/// a warm completion card with the 3 key facts.
|
||||
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
|
||||
use crate::cli::fmt;
|
||||
|
||||
self.settings.onboard_completed = true;
|
||||
|
||||
// Final persist (idempotent — earlier incremental saves already wrote
|
||||
@@ -3096,117 +3306,108 @@ impl SetupWizard {
|
||||
// Write bootstrap env (also idempotent)
|
||||
self.write_bootstrap_env()?;
|
||||
|
||||
// ── Completion card ───────────────────────────────────
|
||||
let sep = fmt::separator(38);
|
||||
|
||||
println!();
|
||||
print_success("Configuration saved to database");
|
||||
println!(" {}", sep);
|
||||
println!();
|
||||
|
||||
// Print summary
|
||||
println!("Configuration Summary:");
|
||||
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
// Title line: checkmark + "ironclaw is ready"
|
||||
println!(
|
||||
" {}\u{2713}{} {}ironclaw is ready{}",
|
||||
fmt::success(),
|
||||
fmt::reset(),
|
||||
fmt::bold_accent(),
|
||||
fmt::reset(),
|
||||
);
|
||||
println!();
|
||||
|
||||
let backend = self
|
||||
.settings
|
||||
.database_backend
|
||||
.as_deref()
|
||||
.unwrap_or("postgres");
|
||||
match backend {
|
||||
"libsql" => {
|
||||
if let Some(ref path) = self.settings.libsql_path {
|
||||
println!(" Database: libSQL ({})", path);
|
||||
} else {
|
||||
println!(" Database: libSQL (default path)");
|
||||
}
|
||||
if self.settings.libsql_url.is_some() {
|
||||
println!(" Turso sync: enabled");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if self.settings.database_url.is_some() {
|
||||
println!(" Database: PostgreSQL (configured)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.settings.secrets_master_key_source {
|
||||
KeySource::Keychain => println!(" Security: OS keychain"),
|
||||
KeySource::Env => println!(" Security: environment variable"),
|
||||
KeySource::None => println!(" Security: disabled"),
|
||||
}
|
||||
|
||||
if let Some(ref provider) = self.settings.llm_backend {
|
||||
let display = match provider.as_str() {
|
||||
"nearai" => "NEAR AI",
|
||||
"anthropic" => "Anthropic",
|
||||
"openai" => "OpenAI",
|
||||
"ollama" => "Ollama",
|
||||
"openai_compatible" => "OpenAI-compatible",
|
||||
"bedrock" => "AWS Bedrock",
|
||||
"openai_codex" => "OpenAI Codex",
|
||||
other => other,
|
||||
};
|
||||
println!(" Provider: {}", display);
|
||||
}
|
||||
|
||||
if let Some(ref model) = self.settings.selected_model {
|
||||
// Fact 1: Provider + model
|
||||
let provider_display = match self.settings.llm_backend.as_deref() {
|
||||
Some("nearai") => "NEAR AI".to_string(),
|
||||
Some("anthropic") => "Anthropic".to_string(),
|
||||
Some("openai") => "OpenAI".to_string(),
|
||||
Some("ollama") => "Ollama".to_string(),
|
||||
Some("openai_compatible") => "OpenAI-compatible".to_string(),
|
||||
Some("bedrock") => "AWS Bedrock".to_string(),
|
||||
Some("openai_codex") => "OpenAI Codex".to_string(),
|
||||
Some("gemini_oauth") => "Gemini CLI".to_string(),
|
||||
Some(other) => other.to_string(),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
let model_suffix = if let Some(ref model) = self.settings.selected_model {
|
||||
// Truncate long model names (char-based to avoid UTF-8 panic)
|
||||
let display = if model.chars().count() > 40 {
|
||||
let truncated: String = model.chars().take(37).collect();
|
||||
let display = if model.chars().count() > 30 {
|
||||
let truncated: String = model.chars().take(27).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
model.clone()
|
||||
};
|
||||
println!(" Model: {}", display);
|
||||
}
|
||||
|
||||
if self.settings.embeddings.enabled {
|
||||
println!(
|
||||
" Embeddings: {} ({})",
|
||||
self.settings.embeddings.provider, self.settings.embeddings.model
|
||||
);
|
||||
format!(" ({})", display)
|
||||
} else {
|
||||
println!(" Embeddings: disabled");
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
let provider_value = format!("{}{}", provider_display, model_suffix);
|
||||
println!(
|
||||
" {}provider{} {}{}{}",
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::accent(),
|
||||
provider_value,
|
||||
fmt::reset(),
|
||||
);
|
||||
|
||||
if let Some(ref tunnel_url) = self.settings.tunnel.public_url {
|
||||
println!(" Tunnel: {} (static)", tunnel_url);
|
||||
} else if let Some(ref provider) = self.settings.tunnel.provider {
|
||||
println!(" Tunnel: {} (managed, starts at boot)", provider);
|
||||
}
|
||||
// Fact 2: Database
|
||||
let db_display = match self.settings.database_backend.as_deref() {
|
||||
Some("libsql") => "libSQL".to_string(),
|
||||
Some("postgres") | Some("postgresql") => "PostgreSQL".to_string(),
|
||||
Some(other) => other.to_string(),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
println!(
|
||||
" {}database{} {}{}{}",
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::accent(),
|
||||
db_display,
|
||||
fmt::reset(),
|
||||
);
|
||||
|
||||
let has_tunnel =
|
||||
self.settings.tunnel.public_url.is_some() || self.settings.tunnel.provider.is_some();
|
||||
|
||||
println!(" Channels:");
|
||||
println!(" - CLI/TUI: enabled");
|
||||
|
||||
if self.settings.channels.http_enabled {
|
||||
let port = self.settings.channels.http_port.unwrap_or(8080);
|
||||
println!(" - HTTP: enabled (port {})", port);
|
||||
}
|
||||
|
||||
for channel_name in &self.settings.channels.wasm_channels {
|
||||
let mode = if has_tunnel { "webhook" } else { "polling" };
|
||||
println!(
|
||||
" - {}: enabled ({})",
|
||||
capitalize_first(channel_name),
|
||||
mode
|
||||
);
|
||||
}
|
||||
|
||||
if self.settings.heartbeat.enabled {
|
||||
println!(
|
||||
" Heartbeat: every {} minutes",
|
||||
self.settings.heartbeat.interval_secs / 60
|
||||
);
|
||||
}
|
||||
// Fact 3: Security
|
||||
let security_display = match self.settings.secrets_master_key_source {
|
||||
KeySource::Keychain => "OS keychain",
|
||||
KeySource::Env => "environment variable",
|
||||
KeySource::None => "disabled",
|
||||
};
|
||||
println!(
|
||||
" {}security{} {}{}{}",
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::accent(),
|
||||
security_display,
|
||||
fmt::reset(),
|
||||
);
|
||||
|
||||
println!();
|
||||
println!("To start the agent, run:");
|
||||
println!(" ironclaw");
|
||||
println!(" {}", sep);
|
||||
println!();
|
||||
println!("To change settings later:");
|
||||
println!(" ironclaw config set <setting> <value>");
|
||||
println!(" ironclaw onboard");
|
||||
|
||||
// Action hints
|
||||
println!(
|
||||
" {}Start chatting:{} {}ironclaw{}",
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::bold_accent(),
|
||||
fmt::reset(),
|
||||
);
|
||||
println!(
|
||||
" {}Full setup:{} {}ironclaw onboard{}",
|
||||
fmt::dim(),
|
||||
fmt::reset(),
|
||||
fmt::bold_accent(),
|
||||
fmt::reset(),
|
||||
);
|
||||
println!();
|
||||
|
||||
if self.config.quick {
|
||||
@@ -3551,6 +3752,7 @@ mod tests {
|
||||
channels_only: false,
|
||||
provider_only: false,
|
||||
quick: false,
|
||||
steps: vec![],
|
||||
};
|
||||
let wizard = SetupWizard::with_config(config);
|
||||
assert!(wizard.config.skip_auth);
|
||||
|
||||
@@ -604,7 +604,7 @@ impl ToolRegistry {
|
||||
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
|
||||
.await;
|
||||
|
||||
tracing::info!("Registered software builder tool");
|
||||
tracing::debug!("Registered software builder tool");
|
||||
builder
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ impl WasmToolLoader {
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
name = name,
|
||||
wasm_path = %wasm_path.display(),
|
||||
"Loaded WASM tool from file"
|
||||
@@ -306,7 +306,7 @@ impl WasmToolLoader {
|
||||
}
|
||||
|
||||
if !results.loaded.is_empty() {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
count = results.loaded.len(),
|
||||
tools = ?results.loaded,
|
||||
"Loaded WASM tools from directory"
|
||||
|
||||
@@ -312,7 +312,7 @@ impl WasmToolRuntime {
|
||||
.insert(prepared.name.clone(), Arc::clone(&prepared));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
name = %prepared.name,
|
||||
"Prepared WASM tool for execution"
|
||||
);
|
||||
|
||||
+3
-3
@@ -190,7 +190,7 @@ pub async fn start_managed_tunnel(
|
||||
mut config: crate::config::Config,
|
||||
) -> (crate::config::Config, Option<Box<dyn Tunnel>>) {
|
||||
if config.tunnel.public_url.is_some() {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Static tunnel URL in use: {}",
|
||||
config.tunnel.public_url.as_deref().unwrap_or("?")
|
||||
);
|
||||
@@ -216,7 +216,7 @@ pub async fn start_managed_tunnel(
|
||||
|
||||
match create_tunnel(provider_config) {
|
||||
Ok(Some(tunnel)) => {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Starting {} tunnel on {}:{}...",
|
||||
tunnel.name(),
|
||||
gateway_host,
|
||||
@@ -224,7 +224,7 @@ pub async fn start_managed_tunnel(
|
||||
);
|
||||
match tunnel.start(gateway_host, gateway_port).await {
|
||||
Ok(url) => {
|
||||
tracing::info!("Tunnel started: {}", url);
|
||||
tracing::debug!("Tunnel started: {}", url);
|
||||
config.tunnel.public_url = Some(url);
|
||||
(config, Some(tunnel))
|
||||
}
|
||||
|
||||
@@ -558,6 +558,10 @@ impl Workspace {
|
||||
/// which uses `\n\n`.
|
||||
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
|
||||
let path = normalize_path(path);
|
||||
// Scan system-prompt-injected files for prompt injection.
|
||||
if is_system_prompt_file(&path) && !content.is_empty() {
|
||||
reject_if_injected(&path, content)?;
|
||||
}
|
||||
let doc = self
|
||||
.storage
|
||||
.get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -773,7 +773,7 @@ impl TestRigBuilder {
|
||||
// 7. Create TestChannel and ChannelManager.
|
||||
// When testing bootstrap, the channel must be named "gateway" because
|
||||
// the bootstrap greeting targets only the gateway channel.
|
||||
let test_channel = if keep_bootstrap {
|
||||
let test_channel = if self.keep_bootstrap {
|
||||
Arc::new(TestChannel::new().with_name("gateway"))
|
||||
} else {
|
||||
Arc::new(TestChannel::new())
|
||||
|
||||
Reference in New Issue
Block a user