Files
optimclaw/src/setup/prompts.rs
T
a09c023642 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]>
2026-03-21 23:50:49 -07:00

457 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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("");
}
}