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]>
457 lines
13 KiB
Rust
457 lines
13 KiB
Rust
//! Interactive prompt utilities for the setup wizard.
|
||
//!
|
||
//! Provides terminal UI components for:
|
||
//! - Single selection menus
|
||
//! - Multi-select with toggles
|
||
//! - Password/secret input (hidden)
|
||
//! - Yes/no confirmations
|
||
//! - Styled headers and step indicators
|
||
|
||
use std::io::{self, Write};
|
||
|
||
use crossterm::{
|
||
cursor,
|
||
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||
execute,
|
||
style::{Color, Print, ResetColor, SetForegroundColor},
|
||
terminal::{self, ClearType},
|
||
};
|
||
use secrecy::SecretString;
|
||
|
||
/// Drain any residual key events already queued in the terminal buffer.
|
||
///
|
||
/// On Windows, transitioning between raw mode and cooked mode (or between
|
||
/// successive raw-mode prompts) can leave stale events (e.g. the Release
|
||
/// half of an Enter keypress) in the queue. Consuming them with a
|
||
/// non-blocking poll prevents the next prompt from mis-firing.
|
||
fn drain_pending_events() {
|
||
while event::poll(std::time::Duration::ZERO).unwrap_or(false) {
|
||
let _ = event::read();
|
||
}
|
||
}
|
||
|
||
/// Display a numbered menu and get user selection.
|
||
///
|
||
/// Returns the index (0-based) of the selected option.
|
||
/// Pressing Enter without input selects the first option (index 0).
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// let choice = select_one("Choose an option:", &["Option A", "Option B"]);
|
||
/// ```
|
||
pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
|
||
let mut stdout = io::stdout();
|
||
|
||
// Print prompt
|
||
writeln!(stdout, "{}", prompt)?;
|
||
writeln!(stdout)?;
|
||
|
||
// Print options
|
||
for (i, option) in options.iter().enumerate() {
|
||
writeln!(stdout, " [{}] {}", i + 1, option)?;
|
||
}
|
||
writeln!(stdout)?;
|
||
|
||
loop {
|
||
print!("> ");
|
||
stdout.flush()?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
let input = input.trim();
|
||
|
||
// Handle empty input as first option
|
||
if input.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
|
||
// Parse number
|
||
if let Ok(num) = input.parse::<usize>()
|
||
&& num >= 1
|
||
&& num <= options.len()
|
||
{
|
||
return Ok(num - 1);
|
||
}
|
||
|
||
writeln!(
|
||
stdout,
|
||
"Invalid choice. Please enter a number 1-{}.",
|
||
options.len()
|
||
)?;
|
||
}
|
||
}
|
||
|
||
/// Multi-select with space to toggle, enter to confirm.
|
||
///
|
||
/// `options` is a slice of (label, initially_selected) tuples.
|
||
/// Returns indices of selected options.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// let selected = select_many("Select channels:", &[
|
||
/// ("CLI/TUI", true),
|
||
/// ("HTTP webhook", false),
|
||
/// ("Telegram", false),
|
||
/// ])?;
|
||
/// ```
|
||
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
|
||
if options.is_empty() {
|
||
return Ok(vec![]);
|
||
}
|
||
|
||
let mut stdout = io::stdout();
|
||
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
|
||
let mut cursor_pos = 0;
|
||
|
||
terminal::enable_raw_mode()?;
|
||
drain_pending_events();
|
||
execute!(stdout, cursor::Hide)?;
|
||
|
||
let result = (|| {
|
||
loop {
|
||
// Clear and redraw
|
||
execute!(stdout, cursor::MoveToColumn(0))?;
|
||
|
||
writeln!(stdout, "{}\r", prompt)?;
|
||
writeln!(stdout, "\r")?;
|
||
writeln!(
|
||
stdout,
|
||
" (Use arrow keys to navigate, space to toggle, enter to confirm)\r"
|
||
)?;
|
||
writeln!(stdout, "\r")?;
|
||
|
||
for (i, (label, _)) in options.iter().enumerate() {
|
||
if i == cursor_pos {
|
||
// Cursor line: cyan cursor, then colored checkbox
|
||
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||
write!(stdout, " \u{25b8} ")?;
|
||
if selected[i] {
|
||
execute!(stdout, SetForegroundColor(Color::Green))?;
|
||
write!(stdout, "[\u{2713}]")?;
|
||
} else {
|
||
execute!(stdout, SetForegroundColor(Color::DarkGrey))?;
|
||
write!(stdout, "[\u{00b7}]")?;
|
||
}
|
||
execute!(stdout, SetForegroundColor(Color::Cyan))?;
|
||
writeln!(stdout, " {}\r", label)?;
|
||
execute!(stdout, ResetColor)?;
|
||
} else {
|
||
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)?;
|
||
}
|
||
}
|
||
|
||
stdout.flush()?;
|
||
|
||
// Read key — only act on Press events to avoid double-firing
|
||
// from Release/Repeat events on Windows.
|
||
if let Event::Key(KeyEvent {
|
||
code,
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
}) = event::read()?
|
||
{
|
||
match code {
|
||
KeyCode::Up => {
|
||
cursor_pos = cursor_pos.saturating_sub(1);
|
||
}
|
||
KeyCode::Down => {
|
||
if cursor_pos < options.len() - 1 {
|
||
cursor_pos += 1;
|
||
}
|
||
}
|
||
KeyCode::Char(' ') => {
|
||
selected[cursor_pos] = !selected[cursor_pos];
|
||
}
|
||
KeyCode::Enter => {
|
||
break;
|
||
}
|
||
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
// Move cursor up to redraw
|
||
execute!(
|
||
stdout,
|
||
cursor::MoveUp((options.len() + 4) as u16),
|
||
terminal::Clear(ClearType::FromCursorDown)
|
||
)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
})();
|
||
|
||
// Cleanup
|
||
execute!(stdout, cursor::Show)?;
|
||
terminal::disable_raw_mode()?;
|
||
writeln!(stdout)?;
|
||
|
||
result?;
|
||
|
||
Ok(selected
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, &s)| if s { Some(i) } else { None })
|
||
.collect())
|
||
}
|
||
|
||
/// Password/secret input with hidden characters.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// let token = secret_input("Bot token")?;
|
||
/// ```
|
||
pub fn secret_input(prompt: &str) -> io::Result<SecretString> {
|
||
let mut stdout = io::stdout();
|
||
|
||
print!("{}: ", prompt);
|
||
stdout.flush()?;
|
||
|
||
terminal::enable_raw_mode()?;
|
||
let result = read_secret_line();
|
||
terminal::disable_raw_mode()?;
|
||
|
||
writeln!(stdout)?;
|
||
result
|
||
}
|
||
|
||
fn read_secret_line() -> io::Result<SecretString> {
|
||
let mut input = String::new();
|
||
let mut stdout = io::stdout();
|
||
|
||
drain_pending_events();
|
||
|
||
loop {
|
||
// Only act on Press events to avoid double-firing from
|
||
// Release/Repeat events on Windows.
|
||
if let Event::Key(KeyEvent {
|
||
code,
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
..
|
||
}) = event::read()?
|
||
{
|
||
match code {
|
||
KeyCode::Enter => {
|
||
break;
|
||
}
|
||
KeyCode::Backspace => {
|
||
if !input.is_empty() {
|
||
input.pop();
|
||
execute!(stdout, Print("\x08 \x08"))?;
|
||
stdout.flush()?;
|
||
}
|
||
}
|
||
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
|
||
}
|
||
KeyCode::Char(c) => {
|
||
input.push(c);
|
||
execute!(stdout, Print('*'))?;
|
||
stdout.flush()?;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(SecretString::from(input))
|
||
}
|
||
|
||
/// Yes/no confirmation prompt.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// if confirm("Enable Telegram channel?", false)? {
|
||
/// // ...
|
||
/// }
|
||
/// ```
|
||
pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
|
||
let mut stdout = io::stdout();
|
||
|
||
let hint = if default { "[Y/n]" } else { "[y/N]" };
|
||
print!("{} {} ", prompt, hint);
|
||
stdout.flush()?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
let input = input.trim().to_lowercase();
|
||
|
||
Ok(match input.as_str() {
|
||
"" => default,
|
||
"y" | "yes" => true,
|
||
"n" | "no" => false,
|
||
_ => default,
|
||
})
|
||
}
|
||
|
||
/// Print a minimal wordmark banner.
|
||
pub fn print_banner() {
|
||
use crate::cli::fmt;
|
||
println!();
|
||
println!(" {}ironclaw{}", fmt::bold_accent(), fmt::reset());
|
||
println!();
|
||
}
|
||
|
||
/// Print a styled header box.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// print_header("IronClaw Setup Wizard");
|
||
/// ```
|
||
pub fn print_header(text: &str) {
|
||
let width = text.len() + 4;
|
||
let border = "─".repeat(width);
|
||
|
||
println!();
|
||
println!("┌{}┐", border);
|
||
println!("│ {} │", text);
|
||
println!("└{}┘", border);
|
||
println!();
|
||
}
|
||
|
||
/// Print a compact dot-based step indicator.
|
||
///
|
||
/// `●` = completed (green/success), `◉` = current (accent), `○` = remaining (dim).
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// print_step(3, 5, "Model Selection");
|
||
/// // Output: ● ● ◉ ○ ○ Model Selection
|
||
/// ```
|
||
pub fn print_step(current: usize, total: usize, name: &str) {
|
||
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!();
|
||
}
|
||
|
||
/// Print a success message with green checkmark.
|
||
pub fn print_success(message: &str) {
|
||
let mut stdout = io::stdout();
|
||
let _ = execute!(stdout, SetForegroundColor(Color::Green));
|
||
print!("✓");
|
||
let _ = execute!(stdout, ResetColor);
|
||
println!(" {}", message);
|
||
}
|
||
|
||
/// Print an error message with red X.
|
||
pub fn print_error(message: &str) {
|
||
let mut stderr = io::stderr();
|
||
let _ = execute!(stderr, SetForegroundColor(Color::Red));
|
||
eprint!("✗");
|
||
let _ = execute!(stderr, ResetColor);
|
||
eprintln!(" {}", message);
|
||
}
|
||
|
||
/// Print a warning message with yellow exclamation.
|
||
pub fn print_warning(message: &str) {
|
||
let mut stdout = io::stdout();
|
||
let _ = execute!(stdout, SetForegroundColor(Color::Yellow));
|
||
print!("!");
|
||
let _ = execute!(stdout, ResetColor);
|
||
println!(" {}", message);
|
||
}
|
||
|
||
/// Print an info message with blue info icon.
|
||
pub fn print_info(message: &str) {
|
||
let mut stdout = io::stdout();
|
||
let _ = execute!(stdout, SetForegroundColor(Color::Blue));
|
||
print!("ℹ");
|
||
let _ = execute!(stdout, ResetColor);
|
||
println!(" {}", message);
|
||
}
|
||
|
||
/// Read a simple line of input with a prompt.
|
||
pub fn input(prompt: &str) -> io::Result<String> {
|
||
let mut stdout = io::stdout();
|
||
print!("{}: ", prompt);
|
||
stdout.flush()?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Read an optional line of input (empty returns None).
|
||
pub fn optional_input(prompt: &str, hint: Option<&str>) -> io::Result<Option<String>> {
|
||
let mut stdout = io::stdout();
|
||
|
||
if let Some(h) = hint {
|
||
print!("{} ({}): ", prompt, h);
|
||
} else {
|
||
print!("{}: ", prompt);
|
||
}
|
||
stdout.flush()?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin().read_line(&mut input)?;
|
||
let input = input.trim();
|
||
|
||
if input.is_empty() {
|
||
Ok(None)
|
||
} else {
|
||
Ok(Some(input.to_string()))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
// Interactive tests are difficult to unit test, but we can test the non-interactive parts.
|
||
|
||
#[test]
|
||
fn test_header_length_calculation() {
|
||
// Just verify it doesn't panic with various inputs
|
||
super::print_header("Test");
|
||
super::print_header("A longer header text");
|
||
super::print_header("");
|
||
}
|
||
|
||
#[test]
|
||
fn test_step_indicator() {
|
||
super::print_step(1, 3, "Test Step");
|
||
super::print_step(3, 3, "Final Step");
|
||
}
|
||
|
||
#[test]
|
||
fn test_print_functions_do_not_panic() {
|
||
super::print_success("operation completed");
|
||
super::print_error("something went wrong");
|
||
super::print_info("here is some information");
|
||
// Also test with empty strings
|
||
super::print_success("");
|
||
super::print_error("");
|
||
super::print_info("");
|
||
}
|
||
}
|