mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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]>
297 lines
8.2 KiB
Rust
297 lines
8.2 KiB
Rust
//! 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
|
|
}
|
|
}
|