diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000000..a6132bd4a6 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## $(date +%Y-%m-%d) - [CLI Cursor UX Fix] +**Learning:** This app is a Rust CLI, not a web frontend. Standard web accessibility rules (ARIA labels, DOM structure) do not apply. UX improvements here involve terminal manipulation (using `crossterm` for ANSI output, cursor hiding, colors, text alignment). Hiding the terminal cursor during a `Spinner` animation prevents the cursor from awkwardly jumping or rendering alongside spinner frames. +**Action:** When implementing CLI UX changes that modify terminal state (like hiding a cursor), ALWAYS implement a `Drop` trait to ensure the state (like cursor visibility) is predictably restored if the process is interrupted or panics. diff --git a/.gitignore b/.gitignore index 47adce46c8..de3bbd12e3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ archive/ .omx/ .clawd-agents/ +.port_sessions/ # Rust build artifacts (also in rust/.gitignore) target/ diff --git a/rust/.claw/sessions/session-1775386832313-0.jsonl b/rust/.claw/sessions/session-1775386832313-0.jsonl deleted file mode 100644 index 81f7a9d22b..0000000000 --- a/rust/.claw/sessions/session-1775386832313-0.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"created_at_ms":1775386832313,"session_id":"session-1775386832313-0","type":"session_meta","updated_at_ms":1775386832313,"version":1} -{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"} diff --git a/rust/.claw/sessions/session-1775386842352-0.jsonl b/rust/.claw/sessions/session-1775386842352-0.jsonl deleted file mode 100644 index 4a678ace1a..0000000000 --- a/rust/.claw/sessions/session-1775386842352-0.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1} -{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"} diff --git a/rust/.claw/sessions/session-1775386852257-0.jsonl b/rust/.claw/sessions/session-1775386852257-0.jsonl deleted file mode 100644 index fa8cb0320f..0000000000 --- a/rust/.claw/sessions/session-1775386852257-0.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1} -{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"} diff --git a/rust/.claw/sessions/session-1775386853666-0.jsonl b/rust/.claw/sessions/session-1775386853666-0.jsonl deleted file mode 100644 index d2bd3033ed..0000000000 --- a/rust/.claw/sessions/session-1775386853666-0.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1} -{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"} diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index fd5a6f1c59..0c31b7d9b9 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -1,7 +1,7 @@ use std::fmt::Write as FmtWrite; use std::io::{self, Write}; -use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition}; +use crossterm::cursor::{Hide, MoveToColumn, RestorePosition, SavePosition, Show}; use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize}; use crossterm::terminal::{Clear, ClearType}; use crossterm::{execute, queue}; @@ -47,6 +47,16 @@ impl Default for ColorTheme { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spinner { frame_index: usize, + cursor_hidden: bool, +} + +impl Drop for Spinner { + fn drop(&mut self) { + if self.cursor_hidden { + let mut out = io::stdout(); + let _ = execute!(out, Show); + } + } } impl Spinner { @@ -63,6 +73,10 @@ impl Spinner { theme: &ColorTheme, out: &mut impl Write, ) -> io::Result<()> { + if !self.cursor_hidden { + queue!(out, Hide)?; + self.cursor_hidden = true; + } let frame = Self::FRAMES[self.frame_index % Self::FRAMES.len()]; self.frame_index += 1; queue!( @@ -85,14 +99,19 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!( + let show_result = execute!( out, MoveToColumn(0), Clear(ClearType::CurrentLine), SetForegroundColor(theme.spinner_done), Print(format!("✔ {label}\n")), - ResetColor - )?; + ResetColor, + Show + ); + if show_result.is_ok() { + self.cursor_hidden = false; + } + show_result?; out.flush() } @@ -103,14 +122,19 @@ impl Spinner { out: &mut impl Write, ) -> io::Result<()> { self.frame_index = 0; - execute!( + let show_result = execute!( out, MoveToColumn(0), Clear(ClearType::CurrentLine), SetForegroundColor(theme.spinner_failed), Print(format!("✘ {label}\n")), - ResetColor - )?; + ResetColor, + Show + ); + if show_result.is_ok() { + self.cursor_hidden = false; + } + show_result?; out.flush() } } @@ -909,6 +933,7 @@ fn strip_ansi(input: &str) -> String { #[cfg(test)] mod tests { use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer}; + use std::io::{self, Write}; #[test] fn renders_markdown_with_styling_and_lists() { @@ -1063,4 +1088,48 @@ mod tests { let output = String::from_utf8_lossy(&out); assert!(output.contains("Working")); } + + struct AlwaysFailWriter; + + impl Write for AlwaysFailWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("write failed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn spinner_finish_error_keeps_cursor_hidden_for_drop_recovery() { + let terminal_renderer = TerminalRenderer::new(); + let mut spinner = Spinner::new(); + let mut out = Vec::new(); + spinner + .tick("Working", terminal_renderer.color_theme(), &mut out) + .expect("tick succeeds"); + + let mut failing_out = AlwaysFailWriter; + let result = spinner.finish("Done", terminal_renderer.color_theme(), &mut failing_out); + + assert!(result.is_err()); + assert!(spinner.cursor_hidden); + } + + #[test] + fn spinner_fail_error_keeps_cursor_hidden_for_drop_recovery() { + let terminal_renderer = TerminalRenderer::new(); + let mut spinner = Spinner::new(); + let mut out = Vec::new(); + spinner + .tick("Working", terminal_renderer.color_theme(), &mut out) + .expect("tick succeeds"); + + let mut failing_out = AlwaysFailWriter; + let result = spinner.fail("Done", terminal_renderer.color_theme(), &mut failing_out); + + assert!(result.is_err()); + assert!(spinner.cursor_hidden); + } }