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]>
900 lines
31 KiB
Rust
900 lines
31 KiB
Rust
//! Interactive REPL channel with line editing and markdown rendering.
|
|
//!
|
|
//! Provides the primary CLI interface for interacting with the agent.
|
|
//! Uses rustyline for line editing, history, and tab-completion.
|
|
//! Uses termimad for rendering markdown responses inline.
|
|
//!
|
|
//! ## Commands
|
|
//!
|
|
//! - `/help` - Show available commands
|
|
//! - `/quit` or `/exit` - Exit the REPL
|
|
//! - `/debug` - Toggle debug mode (verbose tool output)
|
|
//! - `/undo` - Undo the last turn
|
|
//! - `/redo` - Redo an undone turn
|
|
//! - `/clear` - Clear the conversation
|
|
//! - `/compact` - Compact the context
|
|
//! - `/new` - Start a new thread
|
|
//! - `yes`/`no`/`always` - Respond to tool approval prompts
|
|
//! - `Esc` - Interrupt current operation
|
|
|
|
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;
|
|
use rustyline::completion::Completer;
|
|
use rustyline::config::Config;
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::highlight::Highlighter;
|
|
use rustyline::hint::Hinter;
|
|
use rustyline::validate::Validator;
|
|
use rustyline::{
|
|
Cmd as ReadlineCmd, CompletionType, ConditionalEventHandler, Editor, Event, EventContext,
|
|
EventHandler, Helper, KeyCode, KeyEvent, Modifiers, RepeatCount,
|
|
};
|
|
use termimad::MadSkin;
|
|
use tokio::sync::mpsc;
|
|
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.
|
|
const CLI_TOOL_RESULT_MAX: usize = 200;
|
|
|
|
/// Max characters for thinking/status messages in the terminal.
|
|
const CLI_STATUS_MAX: usize = 200;
|
|
|
|
/// Slash commands available in the REPL.
|
|
const SLASH_COMMANDS: &[&str] = &[
|
|
"/help",
|
|
"/quit",
|
|
"/exit",
|
|
"/debug",
|
|
"/model",
|
|
"/undo",
|
|
"/redo",
|
|
"/clear",
|
|
"/compact",
|
|
"/new",
|
|
"/interrupt",
|
|
"/version",
|
|
"/tools",
|
|
"/ping",
|
|
"/job",
|
|
"/status",
|
|
"/cancel",
|
|
"/list",
|
|
"/heartbeat",
|
|
"/summarize",
|
|
"/suggest",
|
|
"/thread",
|
|
"/resume",
|
|
];
|
|
|
|
/// Rustyline helper for slash-command tab completion.
|
|
struct ReplHelper;
|
|
|
|
impl Completer for ReplHelper {
|
|
type Candidate = String;
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
_ctx: &rustyline::Context<'_>,
|
|
) -> rustyline::Result<(usize, Vec<String>)> {
|
|
if !line.starts_with('/') {
|
|
return Ok((0, vec![]));
|
|
}
|
|
|
|
let prefix = &line[..pos];
|
|
let matches: Vec<String> = SLASH_COMMANDS
|
|
.iter()
|
|
.filter(|cmd| cmd.starts_with(prefix))
|
|
.map(|cmd| cmd.to_string())
|
|
.collect();
|
|
|
|
Ok((0, matches))
|
|
}
|
|
}
|
|
|
|
impl Hinter for ReplHelper {
|
|
type Hint = String;
|
|
|
|
fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
|
|
if !line.starts_with('/') || pos < line.len() {
|
|
return None;
|
|
}
|
|
|
|
SLASH_COMMANDS
|
|
.iter()
|
|
.find(|cmd| cmd.starts_with(line) && **cmd != line)
|
|
.map(|cmd| cmd[line.len()..].to_string())
|
|
}
|
|
}
|
|
|
|
impl Highlighter for ReplHelper {
|
|
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
|
Cow::Owned(format!("{}{hint}{}", fmt::dim(), fmt::reset()))
|
|
}
|
|
}
|
|
|
|
impl Validator for ReplHelper {}
|
|
impl Helper for ReplHelper {}
|
|
|
|
struct EscInterruptHandler {
|
|
triggered: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl ConditionalEventHandler for EscInterruptHandler {
|
|
fn handle(
|
|
&self,
|
|
_evt: &Event,
|
|
_n: RepeatCount,
|
|
_positive: bool,
|
|
_ctx: &EventContext,
|
|
) -> Option<ReadlineCmd> {
|
|
self.triggered.store(true, Ordering::Relaxed);
|
|
Some(ReadlineCmd::Interrupt)
|
|
}
|
|
}
|
|
|
|
/// 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(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 = smart_truncate(s, max_val_len);
|
|
format!("{}\"{display}\"{}", fmt::success(), fmt::reset())
|
|
}
|
|
other => {
|
|
let rendered = other.to_string();
|
|
smart_truncate(&rendered, max_val_len).into_owned()
|
|
}
|
|
};
|
|
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 = smart_truncate(&pretty, 300);
|
|
truncated
|
|
.lines()
|
|
.map(|l| format!("{indent}{}{l}{}", fmt::dim(), fmt::reset()))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// REPL channel with line editing and markdown rendering.
|
|
pub struct ReplChannel {
|
|
/// Stable owner scope for this REPL instance.
|
|
user_id: String,
|
|
/// Optional single message to send (for -m flag).
|
|
single_message: Option<String>,
|
|
/// Debug mode flag (shared with input thread).
|
|
debug_mode: Arc<AtomicBool>,
|
|
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
|
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 {
|
|
/// Create a new REPL channel.
|
|
pub fn new() -> Self {
|
|
Self::with_user_id("default")
|
|
}
|
|
|
|
/// Create a new REPL channel for a specific owner scope.
|
|
pub fn with_user_id(user_id: impl Into<String>) -> Self {
|
|
Self {
|
|
user_id: user_id.into(),
|
|
single_message: None,
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// Create a REPL channel that sends a single message and exits.
|
|
pub fn with_message(message: String) -> Self {
|
|
Self::with_message_for_user("default", message)
|
|
}
|
|
|
|
/// Create a REPL channel that sends a single message for a specific owner scope and exits.
|
|
pub fn with_message_for_user(user_id: impl Into<String>, message: String) -> Self {
|
|
Self {
|
|
user_id: user_id.into(),
|
|
single_message: Some(message),
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// Suppress the one-liner startup banner (boot screen will be shown instead).
|
|
pub fn suppress_banner(&self) {
|
|
self.suppress_banner.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
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 {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
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}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}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!();
|
|
}
|
|
|
|
/// Get the history file path (~/.ironclaw/history).
|
|
fn history_path() -> std::path::PathBuf {
|
|
ironclaw_base_dir().join("history")
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for ReplChannel {
|
|
fn name(&self) -> &str {
|
|
"repl"
|
|
}
|
|
|
|
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 || {
|
|
let sys_tz = crate::timezone::detect_system_timezone().name().to_string();
|
|
|
|
// Single message mode: send it and return
|
|
if let Some(msg) = single_message {
|
|
let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(incoming);
|
|
// Ensure the agent exits after handling exactly one turn in -m mode,
|
|
// even when other channels (gateway/http) are enabled.
|
|
let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit"));
|
|
return;
|
|
}
|
|
|
|
// Set up rustyline
|
|
let config = Config::builder()
|
|
.history_ignore_dups(true)
|
|
.expect("valid config")
|
|
.auto_add_history(true)
|
|
.completion_type(CompletionType::List)
|
|
.build();
|
|
|
|
let mut rl = match Editor::with_config(config) {
|
|
Ok(editor) => editor,
|
|
Err(e) => {
|
|
eprintln!("Failed to initialize line editor: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
rl.set_helper(Some(ReplHelper));
|
|
|
|
rl.bind_sequence(
|
|
KeyEvent(KeyCode::Esc, Modifiers::NONE),
|
|
EventHandler::Conditional(Box::new(EscInterruptHandler {
|
|
triggered: Arc::clone(&esc_interrupt_triggered_for_thread),
|
|
})),
|
|
);
|
|
|
|
// Load history
|
|
let hist_path = history_path();
|
|
if let Some(parent) = hist_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let _ = rl.load_history(&hist_path);
|
|
|
|
if !suppress_banner.load(Ordering::Relaxed) {
|
|
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) {
|
|
format!(
|
|
"{}[debug]{} {}\u{203A}{} ",
|
|
fmt::warning(),
|
|
fmt::reset(),
|
|
fmt::bold_accent(),
|
|
fmt::reset()
|
|
)
|
|
} else {
|
|
format!("{}\u{203A}{} ", fmt::bold_accent(), fmt::reset())
|
|
};
|
|
|
|
match rl.readline(&prompt) {
|
|
Ok(line) => {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Handle local REPL commands (only commands that need
|
|
// immediate local handling stay here)
|
|
match line.to_lowercase().as_str() {
|
|
"/quit" | "/exit" => {
|
|
// Forward shutdown command so the agent loop exits even
|
|
// when other channels (e.g. web gateway) are still active.
|
|
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
break;
|
|
}
|
|
"/help" => {
|
|
print_help();
|
|
continue;
|
|
}
|
|
"/debug" => {
|
|
let current = debug_mode.load(Ordering::Relaxed);
|
|
debug_mode.store(!current, Ordering::Relaxed);
|
|
if !current {
|
|
println!("{}debug mode on{}", fmt::dim(), fmt::reset());
|
|
} else {
|
|
println!("{}debug mode off{}", fmt::dim(), fmt::reset());
|
|
}
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
Err(ReadlineError::Interrupted) => {
|
|
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
|
|
// Esc: interrupt current operation and keep REPL open.
|
|
let msg = IncomingMessage::new("repl", &user_id, "/interrupt")
|
|
.with_timezone(&sys_tz);
|
|
if tx.blocking_send(msg).is_err() {
|
|
break;
|
|
}
|
|
} else {
|
|
// Ctrl+C (VINTR): request graceful shutdown.
|
|
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
break;
|
|
}
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
// Ctrl+D in interactive mode: graceful shutdown.
|
|
// In daemon mode (stdin = /dev/null, no TTY), EOF arrives
|
|
// immediately — just drop the REPL thread silently so other
|
|
// channels (gateway, telegram, …) keep running.
|
|
if std::io::stdin().is_terminal() {
|
|
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
|
.with_timezone(&sys_tz);
|
|
let _ = tx.blocking_send(msg);
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Input error: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save history on exit
|
|
let _ = rl.save_history(&history_path());
|
|
});
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
_msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
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!("{}", fmt::separator(sep_width));
|
|
|
|
// Render markdown
|
|
let skin = make_skin();
|
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
|
|
|
print!("{text}");
|
|
println!();
|
|
// Unlock stdin so readline can resume
|
|
self.stdin_locked.store(false, Ordering::Relaxed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
_metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
let debug = self.is_debug();
|
|
|
|
match status {
|
|
StatusUpdate::Thinking(msg) => {
|
|
self.clear_transient();
|
|
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
|
eprintln!(" {}\u{25CB} {display}{}", fmt::dim(), fmt::reset());
|
|
self.transient_lines.store(1, Ordering::Relaxed);
|
|
}
|
|
StatusUpdate::ToolStarted { name } => {
|
|
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!(" {}\u{25CF} {name}{}", fmt::success(), fmt::reset());
|
|
} else {
|
|
eprintln!(" {}\u{2717} {name} (failed){}", fmt::error(), fmt::reset());
|
|
}
|
|
}
|
|
StatusUpdate::ToolResult { name: _, preview } => {
|
|
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
|
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) {
|
|
self.clear_transient();
|
|
let sep_width = fmt::term_width().min(80);
|
|
eprintln!("{}", fmt::separator(sep_width));
|
|
}
|
|
print!("{chunk}");
|
|
let _ = io::stdout().flush();
|
|
}
|
|
StatusUpdate::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
} => {
|
|
eprintln!(
|
|
" {}[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!(" {}{display}{}", fmt::dim(), fmt::reset());
|
|
}
|
|
}
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: _,
|
|
tool_name,
|
|
description: _,
|
|
parameters,
|
|
allow_always,
|
|
} => {
|
|
self.clear_transient();
|
|
let pipe = format!("{}│{}", fmt::accent(), fmt::reset());
|
|
|
|
// Header: ◆ tool requires approval
|
|
eprintln!();
|
|
eprintln!(
|
|
" {}\u{25C6} {}{tool_name}{} requires approval",
|
|
fmt::accent(),
|
|
fmt::bold(),
|
|
fmt::reset()
|
|
);
|
|
|
|
// 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!(" {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,
|
|
instructions,
|
|
setup_url,
|
|
..
|
|
} => {
|
|
eprintln!();
|
|
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!(" {}{url}{}", fmt::link(), fmt::reset());
|
|
}
|
|
eprintln!();
|
|
}
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
} => {
|
|
if success {
|
|
eprintln!(
|
|
"{} {extension_name}: {message}{}",
|
|
fmt::success(),
|
|
fmt::reset()
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"{} {extension_name}: {message}{}",
|
|
fmt::error(),
|
|
fmt::reset()
|
|
);
|
|
}
|
|
}
|
|
StatusUpdate::ImageGenerated { path, .. } => {
|
|
if let Some(ref p) = path {
|
|
eprintln!("{} [image] {p}{}", fmt::accent(), fmt::reset());
|
|
} else {
|
|
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(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
_user_id: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let skin = make_skin();
|
|
let width = fmt::term_width();
|
|
|
|
eprintln!("{}\u{25CF}{} notification", fmt::accent(), fmt::reset());
|
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
|
eprint!("{text}");
|
|
eprintln!();
|
|
Ok(())
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use futures::StreamExt;
|
|
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn single_message_mode_sends_message_then_quit() {
|
|
let repl = ReplChannel::with_message("hi".to_string());
|
|
let mut stream = repl.start().await.expect("repl start should succeed");
|
|
|
|
let first = stream.next().await.expect("first message missing");
|
|
assert_eq!(first.channel, "repl");
|
|
assert_eq!(first.content, "hi");
|
|
|
|
let second = stream.next().await.expect("quit message missing");
|
|
assert_eq!(second.channel, "repl");
|
|
assert_eq!(second.content, "/quit");
|
|
|
|
assert!(
|
|
stream.next().await.is_none(),
|
|
"stream should end after /quit"
|
|
);
|
|
}
|
|
}
|