Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = { version = "2.5.8", features = ["serde"] }
terminal_size = "0.4"
unicode-width = "0.2"
backon = "1.5.2"
eserde = "0.1.7"
uuid = { version = "1.23.0", features = [
Expand Down
12 changes: 6 additions & 6 deletions crates/forge_main/src/stream_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ impl<P: ConsoleWriter> Clone for SharedSpinner<P> {
}
}

impl<P: ConsoleWriter> SharedSpinner<P> {
impl<P: ConsoleWriter + 'static> SharedSpinner<P> {
/// Creates a new shared spinner from a SpinnerManager.
pub fn new(spinner: SpinnerManager<P>) -> Self {
Self(Arc::new(Mutex::new(spinner)))
Expand Down Expand Up @@ -95,7 +95,7 @@ fn term_width() -> usize {
/// Coordinates between markdown rendering and spinner visibility:
/// - Stops spinner when content is being written
/// - Restarts spinner when idle
pub struct StreamingWriter<P: ConsoleWriter> {
pub struct StreamingWriter<P: ConsoleWriter + 'static> {
active: Option<ActiveRenderer<P>>,
spinner: SharedSpinner<P>,
printer: Arc<P>,
Expand Down Expand Up @@ -155,12 +155,12 @@ impl<P: ConsoleWriter + 'static> StreamingWriter<P> {
}

/// Active renderer with its style.
struct ActiveRenderer<P: ConsoleWriter> {
struct ActiveRenderer<P: ConsoleWriter + 'static> {
renderer: StreamdownRenderer<StreamDirectWriter<P>>,
style: Style,
}

impl<P: ConsoleWriter> ActiveRenderer<P> {
impl<P: ConsoleWriter + 'static> ActiveRenderer<P> {
pub fn push(&mut self, text: &str) -> Result<()> {
self.renderer.push(text)?;
Ok(())
Expand All @@ -179,7 +179,7 @@ struct StreamDirectWriter<P: ConsoleWriter> {
style: Style,
}

impl<P: ConsoleWriter> StreamDirectWriter<P> {
impl<P: ConsoleWriter + 'static> StreamDirectWriter<P> {
fn pause_spinner(&self) {
let _ = self.spinner.stop(None);
}
Expand All @@ -196,7 +196,7 @@ impl<P: ConsoleWriter> Drop for StreamDirectWriter<P> {
}
}

impl<P: ConsoleWriter> io::Write for StreamDirectWriter<P> {
impl<P: ConsoleWriter + 'static> io::Write for StreamDirectWriter<P> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.pause_spinner();

Expand Down
14 changes: 14 additions & 0 deletions crates/forge_main/src/zsh/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,20 @@ mod tests {
}
}

#[test]
fn test_generated_plugin_wraps_zle_commands_with_osc133_markers() {
use pretty_assertions::assert_eq;

let fixture = generate_zsh_plugin().unwrap();
let actual = fixture.contains(
" _forge_osc133_emit \"B\"\n _forge_osc133_emit \"C\"\n case \"$user_action\" in",
) && fixture.contains(
" local action_status=$?\n _forge_osc133_emit \"D;$action_status\"\n _forge_osc133_emit \"A\"\n _forge_reset",
);
let expected = true;
assert_eq!(actual, expected);
}

#[test]
fn test_setup_zsh_integration_without_nerd_font_config() {
use tempfile::TempDir;
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_spinner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ rust-version.workspace = true
[dependencies]
anyhow.workspace = true
colored.workspace = true
console.workspace = true
tokio.workspace = true
forge_domain.workspace = true
indicatif = "0.18.4"
terminal_size.workspace = true
unicode-width.workspace = true
rand = "0.10.0"

[dev-dependencies]
Expand Down
210 changes: 152 additions & 58 deletions crates/forge_spinner/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::sync::Arc;
use std::time::Duration;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};

use anyhow::Result;
use colored::Colorize;
use forge_domain::ConsoleWriter;
use indicatif::{ProgressBar, ProgressState, ProgressStyle};
use rand::RngExt;

mod progress_bar;
Expand All @@ -13,6 +14,124 @@ pub use progress_bar::*;

const TICK_DURATION_MS: u64 = 60;
const TICKS: &[&str; 10] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const MIN_TERMINAL_WIDTH: usize = 12;
const WRAP_GUARD_COLUMNS: usize = 8;

fn terminal_width() -> usize {
terminal_size::terminal_size()
.map(|(width, _)| width.0 as usize)
.unwrap_or(80)
}

fn visible_width(value: &str) -> usize {
console::measure_text_width(value)
}

fn truncate_to_visible_width(value: &str, max_width: usize) -> String {
let mut output = value.to_string();

while visible_width(&output) > max_width {
if output.pop().is_none() {
break;
}
}

output
}

fn styled_loader_line(
tick: &str,
message: &str,
elapsed: Duration,
terminal_width: usize,
) -> String {
let elapsed = format_elapsed_time(elapsed);
let suffix = "· Ctrl+C to interrupt";
let max_width = terminal_width
.saturating_sub(WRAP_GUARD_COLUMNS)
.max(MIN_TERMINAL_WIDTH);

let tick = tick.green().to_string();
let elapsed = elapsed.white().to_string();
let suffix = suffix.white().dimmed().to_string();
let fixed = format!("{tick} {elapsed} {suffix}");
let message_width = max_width.saturating_sub(visible_width(&fixed)).max(1);
let message = truncate_to_visible_width(message, message_width)
.green()
.bold()
.to_string();
let styled = format!("{tick} {message} {elapsed} {suffix}");

truncate_to_visible_width(&styled, max_width)
}

struct ActiveSpinner<P: ConsoleWriter> {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
started_at: Instant,
accumulated_elapsed: Duration,
printer: Arc<P>,
}

impl<P: ConsoleWriter + 'static> ActiveSpinner<P> {
fn start(printer: Arc<P>, accumulated_elapsed: Duration, message: String) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let stop_signal = Arc::clone(&stop);
let thread_printer = Arc::clone(&printer);
let started_at = Instant::now();

let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(TICK_DURATION_MS));
while !stop_signal.load(Ordering::Acquire) {
let elapsed = accumulated_elapsed + started_at.elapsed();
let tick_index = ((elapsed.as_millis() / TICK_DURATION_MS as u128)
% TICKS.len() as u128) as usize;
let tick = TICKS.get(tick_index).unwrap_or(&"⠋");
let line = styled_loader_line(tick, &message, elapsed, terminal_width());

let _ = thread_printer.write_err(format!("\r\x1b[2K{line}").as_bytes());
let _ = thread_printer.flush_err();
thread::sleep(Duration::from_millis(TICK_DURATION_MS));
}
});

Self {
stop,
handle: Some(handle),
started_at,
accumulated_elapsed,
printer,
}
}
}

impl<P: ConsoleWriter> ActiveSpinner<P> {
fn elapsed(&self) -> Duration {
self.accumulated_elapsed + self.started_at.elapsed()
}

fn finish(&mut self) -> Duration {
self.stop.store(true, Ordering::Release);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let elapsed = self.elapsed();
let _ = self.printer.write_err(b"\r\x1b[2K");
let _ = self.printer.flush_err();
elapsed
}
}

impl<P: ConsoleWriter> Drop for ActiveSpinner<P> {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let _ = self.printer.write_err(b"\r\x1b[2K");
let _ = self.printer.flush_err();
}
}

/// Formats elapsed time into a compact string representation.
///
Expand Down Expand Up @@ -43,20 +162,19 @@ fn format_elapsed_time(duration: Duration) -> String {

/// Manages spinner functionality for the UI.
///
/// Uses indicatif's built-in `{elapsed}` template for time display,
/// eliminating the need for a background task to update the message.
/// Accumulated time is preserved across start/stop cycles using
/// `with_elapsed()`. Spinner tick position is also preserved to maintain
/// smooth animation continuity.
/// Renders the loader through a resize-safe manual terminal writer that clears
/// and redraws a single truncated line on each tick. Accumulated time is
/// preserved across start/stop cycles so paused output can resume without
/// resetting the elapsed timer.
pub struct SpinnerManager<P: ConsoleWriter> {
spinner: Option<ProgressBar>,
spinner: Option<ActiveSpinner<P>>,
accumulated_elapsed: Duration,
word_index: Option<usize>,
message: Option<String>,
printer: Arc<P>,
}

impl<P: ConsoleWriter> SpinnerManager<P> {
impl<P: ConsoleWriter + 'static> SpinnerManager<P> {
/// Creates a new SpinnerManager with the given output printer.
pub fn new(printer: Arc<P>) -> Self {
Self {
Expand Down Expand Up @@ -96,48 +214,17 @@ impl<P: ConsoleWriter> SpinnerManager<P> {

self.message = Some(word.clone());

// Create the spinner with accumulated elapsed time
// Use custom elapsed formatter for "01s", "1:01m", "1:01h" format
let pb = ProgressBar::new_spinner()
.with_elapsed(self.accumulated_elapsed)
.with_style(
ProgressStyle::default_spinner()
.tick_strings(TICKS)
.template("{spinner:.green} {msg} {elapsed_custom:.white} {prefix:.white.dim}")
.unwrap()
.with_key(
"elapsed_custom",
|state: &ProgressState, w: &mut dyn std::fmt::Write| {
let _ = write!(w, "{}", format_elapsed_time(state.elapsed()));
},
),
)
.with_message(word.green().bold().to_string())
.with_prefix("· Ctrl+C to interrupt");

// Preserve spinner tick position for visual continuity
// The spinner has 10 tick positions cycling every 600ms (60ms per tick)
let tick_count: usize = TICKS.len();
let elapsed_ms = self.accumulated_elapsed.as_millis() as u64;
let cycle_ms = TICK_DURATION_MS * tick_count as u64;
let ticks_to_advance = (elapsed_ms % cycle_ms) / TICK_DURATION_MS;

// Advance to the correct tick position
(0..ticks_to_advance).for_each(|_| pb.tick());

pb.enable_steady_tick(Duration::from_millis(TICK_DURATION_MS));

self.spinner = Some(pb);
let spinner = ActiveSpinner::start(self.printer.clone(), self.accumulated_elapsed, word);
self.spinner = Some(spinner);

Ok(())
}

/// Stop the active spinner if any
pub fn stop(&mut self, message: Option<String>) -> Result<()> {
if let Some(spinner) = self.spinner.take() {
if let Some(mut spinner) = self.spinner.take() {
// Capture elapsed time before finishing
self.accumulated_elapsed = spinner.elapsed();
spinner.finish_and_clear();
self.accumulated_elapsed = spinner.finish();
if let Some(msg) = message {
self.println(&msg);
}
Expand All @@ -153,8 +240,9 @@ impl<P: ConsoleWriter> SpinnerManager<P> {
/// Updates the spinner's displayed message.
pub fn set_message(&mut self, message: &str) -> Result<()> {
self.message = Some(message.to_owned());
if let Some(spinner) = &self.spinner {
spinner.set_message(message.green().bold().to_string());
if self.spinner.is_some() {
self.stop(None)?;
self.start(Some(message))?;
}
Ok(())
}
Expand All @@ -170,21 +258,27 @@ impl<P: ConsoleWriter> SpinnerManager<P> {
/// Writes a line to stdout, suspending the spinner if active.
pub fn write_ln(&mut self, message: impl ToString) -> Result<()> {
let msg = message.to_string();
if let Some(spinner) = &self.spinner {
spinner.suspend(|| self.println(&msg));
} else {
self.println(&msg);
let active_message = self.message.clone().filter(|_| self.spinner.is_some());
if self.spinner.is_some() {
self.stop(None)?;
}
self.println(&msg);
if let Some(active_message) = active_message {
self.start(Some(&active_message))?;
}
Ok(())
}

/// Writes a line to stderr, suspending the spinner if active.
pub fn ewrite_ln(&mut self, message: impl ToString) -> Result<()> {
let msg = message.to_string();
if let Some(spinner) = &self.spinner {
spinner.suspend(|| self.eprintln(&msg));
} else {
self.eprintln(&msg);
let active_message = self.message.clone().filter(|_| self.spinner.is_some());
if self.spinner.is_some() {
self.stop(None)?;
}
self.eprintln(&msg);
if let Some(active_message) = active_message {
self.start(Some(&active_message))?;
}
Ok(())
}
Expand All @@ -206,10 +300,10 @@ impl<P: ConsoleWriter> SpinnerManager<P> {

impl<P: ConsoleWriter> Drop for SpinnerManager<P> {
fn drop(&mut self) {
// Stop spinner before flushing to ensure finish_and_clear() is called
// This prevents the spinner from leaving the cursor at column 0 without a
// newline
let _ = self.stop(None);
// Stop spinner before flushing to ensure finish_and_clear() is called.
if let Some(mut spinner) = self.spinner.take() {
self.accumulated_elapsed = spinner.finish();
}
// Flush both stdout and stderr to ensure all output is visible
// This prevents race conditions with shell prompt resets
let _ = self.printer.flush();
Expand Down
2 changes: 1 addition & 1 deletion shell-plugin/lib/context.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function _forge_osc133_should_emit() {
if [[ -n "${KITTY_PID:-}" ]]; then _FORGE_TERM_OSC133_CACHED="1"; return 0; fi
# Detect by TERM_PROGRAM
case "${TERM_PROGRAM:-}" in
WezTerm|iTerm.app|vscode) _FORGE_TERM_OSC133_CACHED="1"; return 0 ;;
WezTerm|iTerm.app|vscode|WarpTerminal) _FORGE_TERM_OSC133_CACHED="1"; return 0 ;;
esac
# Foot terminal
if [[ "${TERM:-}" == "foot"* ]]; then _FORGE_TERM_OSC133_CACHED="1"; return 0; fi
Expand Down
Loading
Loading