mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +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
+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(())
|
||||
|
||||
Reference in New Issue
Block a user