diff --git a/Cargo.lock b/Cargo.lock index 695bd48f01..14b3bff58b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2621,11 +2621,14 @@ version = "0.1.0" dependencies = [ "anyhow", "colored", + "console", "forge_domain", "indicatif", "pretty_assertions", "rand 0.10.1", + "terminal_size", "tokio", + "unicode-width 0.2.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a605fb1da..42f0aee776 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [ diff --git a/crates/forge_main/src/stream_renderer.rs b/crates/forge_main/src/stream_renderer.rs index eac0794b96..153a622da2 100644 --- a/crates/forge_main/src/stream_renderer.rs +++ b/crates/forge_main/src/stream_renderer.rs @@ -22,7 +22,7 @@ impl Clone for SharedSpinner

{ } } -impl SharedSpinner

{ +impl SharedSpinner

{ /// Creates a new shared spinner from a SpinnerManager. pub fn new(spinner: SpinnerManager

) -> Self { Self(Arc::new(Mutex::new(spinner))) @@ -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 { +pub struct StreamingWriter { active: Option>, spinner: SharedSpinner

, printer: Arc

, @@ -155,12 +155,12 @@ impl StreamingWriter

{ } /// Active renderer with its style. -struct ActiveRenderer { +struct ActiveRenderer { renderer: StreamdownRenderer>, style: Style, } -impl ActiveRenderer

{ +impl ActiveRenderer

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

{ +impl StreamDirectWriter

{ fn pause_spinner(&self) { let _ = self.spinner.stop(None); } @@ -196,7 +196,7 @@ impl Drop for StreamDirectWriter

{ } } -impl io::Write for StreamDirectWriter

{ +impl io::Write for StreamDirectWriter

{ fn write(&mut self, buf: &[u8]) -> io::Result { self.pause_spinner(); diff --git a/crates/forge_main/src/zsh/plugin.rs b/crates/forge_main/src/zsh/plugin.rs index 5e96121952..b54e3e9baa 100644 --- a/crates/forge_main/src/zsh/plugin.rs +++ b/crates/forge_main/src/zsh/plugin.rs @@ -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; diff --git a/crates/forge_spinner/Cargo.toml b/crates/forge_spinner/Cargo.toml index a1dc1f76a9..2076ff0429 100644 --- a/crates/forge_spinner/Cargo.toml +++ b/crates/forge_spinner/Cargo.toml @@ -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] diff --git a/crates/forge_spinner/src/lib.rs b/crates/forge_spinner/src/lib.rs index ad9f79ed27..8c28b16078 100644 --- a/crates/forge_spinner/src/lib.rs +++ b/crates/forge_spinner/src/lib.rs @@ -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; @@ -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 { + stop: Arc, + handle: Option>, + started_at: Instant, + accumulated_elapsed: Duration, + printer: Arc

, +} + +impl ActiveSpinner

{ + fn start(printer: Arc

, 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 ActiveSpinner

{ + 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 Drop for ActiveSpinner

{ + 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. /// @@ -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 { - spinner: Option, + spinner: Option>, accumulated_elapsed: Duration, word_index: Option, message: Option, printer: Arc

, } -impl SpinnerManager

{ +impl SpinnerManager

{ /// Creates a new SpinnerManager with the given output printer. pub fn new(printer: Arc

) -> Self { Self { @@ -96,48 +214,17 @@ impl SpinnerManager

{ 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) -> 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); } @@ -153,8 +240,9 @@ impl SpinnerManager

{ /// 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(()) } @@ -170,10 +258,13 @@ impl SpinnerManager

{ /// 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(()) } @@ -181,10 +272,13 @@ impl SpinnerManager

{ /// 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(()) } @@ -206,10 +300,10 @@ impl SpinnerManager

{ impl Drop for SpinnerManager

{ 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(); diff --git a/shell-plugin/lib/context.zsh b/shell-plugin/lib/context.zsh index 02e2f22084..7fda5fa84f 100644 --- a/shell-plugin/lib/context.zsh +++ b/shell-plugin/lib/context.zsh @@ -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 diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 59a8c018b8..c5f13db0e0 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -140,6 +140,12 @@ function forge-accept-line() { # # Naming convention: shell commands should follow Object-Action (e.g., provider-login). # + # ZLE-dispatched Forge commands bypass zsh preexec/precmd hooks, so emit + # OSC 133 markers explicitly. Ghostty uses these markers to distinguish the + # prompt from command output during window resize/reflow. + _forge_osc133_emit "B" + _forge_osc133_emit "C" + # Dispatch to appropriate action handler using pattern matching case "$user_action" in new|n) @@ -201,21 +207,30 @@ function forge-accept-line() { ;; edit|ed) _forge_action_editor "$input_text" + local action_status=$? + _forge_osc133_emit "D;$action_status" + _forge_osc133_emit "A" # Note: editor action intentionally modifies BUFFER and handles its own prompt reset - return + return $action_status ;; commit) _forge_action_commit "$input_text" ;; commit-preview) _forge_action_commit_preview "$input_text" + local action_status=$? + _forge_osc133_emit "D;$action_status" + _forge_osc133_emit "A" # Note: commit action intentionally modifies BUFFER and handles its own prompt reset - return + return $action_status ;; suggest|s) _forge_action_suggest "$input_text" + local action_status=$? + _forge_osc133_emit "D;$action_status" + _forge_osc133_emit "A" # Note: suggest action intentionally modifies BUFFER and handles its own prompt reset - return + return $action_status ;; clone) _forge_action_clone "$input_text" @@ -252,8 +267,13 @@ function forge-accept-line() { ;; esac + local action_status=$? + _forge_osc133_emit "D;$action_status" + _forge_osc133_emit "A" + # Centralized reset after all actions complete # This ensures consistent prompt state without requiring each action to call _forge_reset # Exceptions: editor, commit-preview, and suggest actions return early as they intentionally modify BUFFER _forge_reset + return $action_status }