mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 15:01:15 +00:00
Open-source Agent OS built in Rust. - 14 crates, 1,767+ tests, zero clippy warnings - 7 autonomous Hands (Clip, Lead, Collector, Predictor, Researcher, Twitter, Browser) - 16 security systems (WASM sandbox, Merkle audit trail, taint tracking, Ed25519 signing, SSRF protection, secret zeroization, HMAC-SHA256 mutual auth, and more) - 30 pre-built agents across 4 performance tiers - 40 channel adapters (Telegram, Discord, Slack, WhatsApp, Teams, and 35 more) - 38 built-in tools + MCP client/server + A2A protocol - 26 LLM providers with intelligent routing and cost tracking - 60+ bundled skills with FangHub marketplace - Tauri 2.0 native desktop app - 140+ REST/WS/SSE API endpoints with Alpine.js dashboard - OpenAI-compatible /v1/chat/completions endpoint - One-command install, production-ready
323 lines
8.6 KiB
Rust
323 lines
8.6 KiB
Rust
//! Progress bars and spinners for CLI output.
|
|
//!
|
|
//! Uses raw ANSI escape sequences (no external dependency). Supports:
|
|
//! - Percentage progress bar with visual block characters
|
|
//! - Spinner with label
|
|
//! - OSC 9;4 terminal progress protocol (ConEmu/Windows Terminal/iTerm2)
|
|
//! - Delay suppression for fast operations
|
|
|
|
use std::io::{self, Write};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Default progress bar width (in characters).
|
|
const DEFAULT_BAR_WIDTH: usize = 30;
|
|
|
|
/// Minimum elapsed time before showing progress output. Operations that
|
|
/// complete faster than this threshold produce no visual noise.
|
|
const DELAY_SUPPRESS_MS: u64 = 200;
|
|
|
|
/// Block characters for the progress bar.
|
|
const FILLED: char = '\u{2588}'; // █
|
|
const EMPTY: char = '\u{2591}'; // ░
|
|
|
|
/// Spinner animation frames.
|
|
const SPINNER_FRAMES: &[char] = &[
|
|
'\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}', '\u{2834}', '\u{2826}', '\u{2827}',
|
|
'\u{2807}', '\u{280f}',
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OSC 9;4 progress protocol
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Emit an OSC 9;4 progress sequence (supported by Windows Terminal, ConEmu,
|
|
/// iTerm2). `state`: 1 = set progress, 2 = error, 3 = indeterminate, 0 = clear.
|
|
fn osc_progress(state: u8, percent: u8) {
|
|
// ESC ] 9 ; 4 ; state ; percent ST
|
|
// ST = ESC \ (string terminator)
|
|
let _ = write!(io::stderr(), "\x1b]9;4;{state};{percent}\x1b\\");
|
|
let _ = io::stderr().flush();
|
|
}
|
|
|
|
/// Clear the OSC 9;4 progress indicator.
|
|
fn osc_progress_clear() {
|
|
osc_progress(0, 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ProgressBar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A simple percentage-based progress bar.
|
|
///
|
|
/// ```text
|
|
/// Downloading [████████████░░░░░░░░░░░░░░░░░░] 40% (4/10)
|
|
/// ```
|
|
pub struct ProgressBar {
|
|
label: String,
|
|
total: u64,
|
|
current: u64,
|
|
width: usize,
|
|
start: Instant,
|
|
suppress_until: Duration,
|
|
visible: bool,
|
|
use_osc: bool,
|
|
}
|
|
|
|
impl ProgressBar {
|
|
/// Create a new progress bar.
|
|
///
|
|
/// `label`: text shown before the bar.
|
|
/// `total`: the 100% value.
|
|
pub fn new(label: &str, total: u64) -> Self {
|
|
Self {
|
|
label: label.to_string(),
|
|
total: total.max(1),
|
|
current: 0,
|
|
width: DEFAULT_BAR_WIDTH,
|
|
start: Instant::now(),
|
|
suppress_until: Duration::from_millis(DELAY_SUPPRESS_MS),
|
|
visible: false,
|
|
use_osc: true,
|
|
}
|
|
}
|
|
|
|
/// Set the bar width in characters.
|
|
pub fn width(mut self, w: usize) -> Self {
|
|
self.width = w.max(5);
|
|
self
|
|
}
|
|
|
|
/// Disable delay suppression (always show immediately).
|
|
pub fn no_delay(mut self) -> Self {
|
|
self.suppress_until = Duration::ZERO;
|
|
self
|
|
}
|
|
|
|
/// Disable OSC 9;4 terminal progress protocol.
|
|
pub fn no_osc(mut self) -> Self {
|
|
self.use_osc = false;
|
|
self
|
|
}
|
|
|
|
/// Update progress to `n`.
|
|
pub fn set(&mut self, n: u64) {
|
|
self.current = n.min(self.total);
|
|
self.draw();
|
|
}
|
|
|
|
/// Increment progress by `delta`.
|
|
pub fn inc(&mut self, delta: u64) {
|
|
self.current = (self.current + delta).min(self.total);
|
|
self.draw();
|
|
}
|
|
|
|
/// Mark as finished and clear the line.
|
|
pub fn finish(&mut self) {
|
|
self.current = self.total;
|
|
self.draw();
|
|
if self.visible {
|
|
// Move to next line
|
|
eprintln!();
|
|
}
|
|
if self.use_osc {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
|
|
/// Mark as finished with a message replacing the bar.
|
|
pub fn finish_with_message(&mut self, msg: &str) {
|
|
self.current = self.total;
|
|
if self.visible {
|
|
eprint!("\r\x1b[2K{msg}");
|
|
eprintln!();
|
|
} else if self.start.elapsed() >= self.suppress_until {
|
|
eprintln!("{msg}");
|
|
}
|
|
if self.use_osc {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
|
|
fn draw(&mut self) {
|
|
// Delay suppression: don't render if op is still fast
|
|
if self.start.elapsed() < self.suppress_until && self.current < self.total {
|
|
return;
|
|
}
|
|
|
|
self.visible = true;
|
|
|
|
let pct = (self.current as f64 / self.total as f64 * 100.0) as u8;
|
|
let filled = (self.current as f64 / self.total as f64 * self.width as f64) as usize;
|
|
let empty = self.width.saturating_sub(filled);
|
|
|
|
let bar: String = std::iter::repeat_n(FILLED, filled)
|
|
.chain(std::iter::repeat_n(EMPTY, empty))
|
|
.collect();
|
|
|
|
eprint!(
|
|
"\r\x1b[2K{:<14} [{}] {:>3}% ({}/{})",
|
|
self.label, bar, pct, self.current, self.total
|
|
);
|
|
let _ = io::stderr().flush();
|
|
|
|
if self.use_osc {
|
|
osc_progress(1, pct);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ProgressBar {
|
|
fn drop(&mut self) {
|
|
if self.use_osc && self.visible {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spinner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// An indeterminate spinner for operations without known total.
|
|
///
|
|
/// ```text
|
|
/// ⠋ Loading models...
|
|
/// ```
|
|
pub struct Spinner {
|
|
label: String,
|
|
frame: usize,
|
|
start: Instant,
|
|
suppress_until: Duration,
|
|
visible: bool,
|
|
use_osc: bool,
|
|
}
|
|
|
|
impl Spinner {
|
|
/// Create a spinner with the given label.
|
|
pub fn new(label: &str) -> Self {
|
|
Self {
|
|
label: label.to_string(),
|
|
frame: 0,
|
|
start: Instant::now(),
|
|
suppress_until: Duration::from_millis(DELAY_SUPPRESS_MS),
|
|
visible: false,
|
|
use_osc: true,
|
|
}
|
|
}
|
|
|
|
/// Disable delay suppression.
|
|
pub fn no_delay(mut self) -> Self {
|
|
self.suppress_until = Duration::ZERO;
|
|
self
|
|
}
|
|
|
|
/// Disable OSC 9;4 protocol.
|
|
pub fn no_osc(mut self) -> Self {
|
|
self.use_osc = false;
|
|
self
|
|
}
|
|
|
|
/// Advance the spinner by one frame and redraw.
|
|
pub fn tick(&mut self) {
|
|
if self.start.elapsed() < self.suppress_until {
|
|
return;
|
|
}
|
|
|
|
self.visible = true;
|
|
let ch = SPINNER_FRAMES[self.frame % SPINNER_FRAMES.len()];
|
|
self.frame += 1;
|
|
|
|
eprint!("\r\x1b[2K{ch} {}", self.label);
|
|
let _ = io::stderr().flush();
|
|
|
|
if self.use_osc {
|
|
osc_progress(3, 0);
|
|
}
|
|
}
|
|
|
|
/// Update the label text.
|
|
pub fn set_label(&mut self, label: &str) {
|
|
self.label = label.to_string();
|
|
}
|
|
|
|
/// Stop the spinner and clear the line.
|
|
pub fn finish(&self) {
|
|
if self.visible {
|
|
eprint!("\r\x1b[2K");
|
|
let _ = io::stderr().flush();
|
|
}
|
|
if self.use_osc {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
|
|
/// Stop the spinner and print a final message.
|
|
pub fn finish_with_message(&self, msg: &str) {
|
|
if self.visible {
|
|
eprint!("\r\x1b[2K");
|
|
}
|
|
eprintln!("{msg}");
|
|
if self.use_osc {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Spinner {
|
|
fn drop(&mut self) {
|
|
if self.use_osc && self.visible {
|
|
osc_progress_clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn progress_bar_percentage() {
|
|
let mut pb = ProgressBar::new("Test", 10).no_delay().no_osc();
|
|
pb.set(5);
|
|
assert_eq!(pb.current, 5);
|
|
pb.inc(3);
|
|
assert_eq!(pb.current, 8);
|
|
// Cannot exceed total
|
|
pb.inc(100);
|
|
assert_eq!(pb.current, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn progress_bar_zero_total_no_panic() {
|
|
// total of 0 should be clamped to 1 to avoid division by zero
|
|
let mut pb = ProgressBar::new("Empty", 0).no_delay().no_osc();
|
|
pb.set(0);
|
|
pb.finish();
|
|
assert_eq!(pb.total, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn spinner_frame_advance() {
|
|
let mut sp = Spinner::new("Loading").no_delay().no_osc();
|
|
sp.tick();
|
|
assert_eq!(sp.frame, 1);
|
|
sp.tick();
|
|
assert_eq!(sp.frame, 2);
|
|
sp.finish();
|
|
}
|
|
|
|
#[test]
|
|
fn delay_suppression() {
|
|
// With default suppress_until, a freshly-created bar should NOT
|
|
// become visible on the first draw (elapsed < 200ms).
|
|
let mut pb = ProgressBar::new("Quick", 10).no_osc();
|
|
pb.set(1);
|
|
assert!(!pb.visible);
|
|
}
|
|
}
|