diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 48fa701d..a5365754 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -19,6 +19,7 @@ mod input; mod render; mod setup_wizard; mod tui; +mod tui_update; use std::collections::BTreeSet; use std::env; @@ -7189,7 +7190,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { // The buffer may contain ANSI codes from TerminalRenderer — // strip them before pushing. let captured = String::from_utf8_lossy(&buf); - let plain = strip_ansi(&captured); + let plain = tui_update::strip_ansi(&captured); if !plain.is_empty() { app.push_output(&plain, false); } @@ -7264,59 +7265,38 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { /// Push current CLI/runtime stats into the shared dashboard state. fn update_dashboard(state: &tui::SharedDashboardState, cli: &LiveCli) { - if let Ok(mut ds) = state.write() { - ds.model = cli.model.clone(); - ds.permission_mode = format!("{:?}", cli.permission_mode); - if let Some(rt) = cli.runtime.runtime.as_ref() { - let session = rt.session(); - ds.session_id = Some(session.session_id.clone()); - ds.turn_count = session.messages.len() as u32; - // Estimate token count from message text lengths (rough: ~4 chars per token) - let total_chars: usize = session.messages.iter().map(|m| { - m.blocks.iter().map(|b| match b { - runtime::ContentBlock::Text { text } => text.len(), - runtime::ContentBlock::ToolUse { input, .. } => input.to_string().len(), - runtime::ContentBlock::ToolResult { output, .. } => output.len(), - _ => 0, - }).sum::() - }).sum(); - ds.input_tokens = (total_chars / 4) as u32; - } - ds.provider = std::env::var("CLAWD_PROVIDER").unwrap_or_else(|_| "anthropic".to_string()); - ds.provider_url = std::env::var("ANTHROPIC_BASE_URL") - .or_else(|_| std::env::var("OPENAI_BASE_URL")) - .unwrap_or_default(); + let mut session_id = None; + let mut turn_count = 0u32; + let mut total_chars = 0usize; + + if let Some(rt) = cli.runtime.runtime.as_ref() { + let session = rt.session(); + session_id = Some(session.session_id.clone()); + turn_count = session.messages.len() as u32; + total_chars = session.messages.iter().map(|m| { + m.blocks.iter().map(|b| match b { + runtime::ContentBlock::Text { text } => text.len(), + runtime::ContentBlock::ToolUse { input, .. } => input.to_string().len(), + runtime::ContentBlock::ToolResult { output, .. } => output.len(), + _ => 0, + }).sum::() + }).sum(); } + + let update = tui_update::DashboardUpdate { + model: &cli.model, + permission_mode: &format!("{:?}", cli.permission_mode), + session_id: session_id.as_deref(), + turn_count, + total_chars, + provider: std::env::var("CLAWD_PROVIDER").unwrap_or_else(|_| "anthropic".to_string()), + provider_url: std::env::var("ANTHROPIC_BASE_URL") + .or_else(|_| std::env::var("OPENAI_BASE_URL")) + .unwrap_or_default(), + }; + tui_update::update_dashboard_from(state, &update); } -/// Strip ANSI escape sequences from a string. Used by the TUI path to -/// clean captured output before pushing it into the conversation pane. -fn strip_ansi(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\x1b' && chars.peek() == Some(&'[') { - chars.next(); // consume '[' - // Consume the sequence: parameter bytes (0x30-0x3f), - // intermediate bytes (0x20-0x2f), final byte (0x40-0x7e) - while let Some(&b) = chars.peek() { - match b { - '\x20'..='\x2f' | '\x30'..='\x3f' => { - chars.next(); - } - '\x40'..='\x7e' => { - chars.next(); - break; - } - _ => break, - } - } - } else { - result.push(ch); - } - } - result -} #[derive(Debug, Clone)] struct SessionHandle { diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs index d788cdb5..2bcf2dbd 100644 --- a/rust/crates/rusty-claude-cli/src/tui.rs +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -297,7 +297,7 @@ impl TuiApp { // runtime's stdout rendering. The conversation pane renders plain text // with ratatui styles, so ANSI bytes would corrupt the layout and // confuse wrap_line()'s character counting. - let clean = strip_ansi_escapes(text); + let clean = crate::tui_update::strip_ansi(text); for raw_line in clean.lines() { self.conversation.push(ConversationLine { text: raw_line.to_string(), @@ -911,36 +911,4 @@ fn kv<'a>(key: &str, val: &str, val_color: Color) -> Line<'a> { ]) } -/// Strip ANSI escape sequences from a string. -/// -/// The runtime's stdout rendering (`TerminalRenderer::markdown_to_ansi`) -/// produces ANSI-colored output for the full terminal width. When that text -/// leaks into the conversation pane (e.g. via error messages or raw captures) -/// the ANSI bytes corrupt ratatui's character-counting and word-wrapping. -/// This function removes them so the pane always works with plain text; styling -/// is handled by ratatui's `Style` system instead. -fn strip_ansi_escapes(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - // ESC sequence: ESC [ ... - if chars.peek() == Some(&'[') { - chars.next(); // consume '[' - for next in chars.by_ref() { - // The final byte of a CSI sequence is 0x40..=0x7E - if next.is_ascii_alphabetic() || ('@'..='~').contains(&next) { - break; - } - } - } else { - // Bare ESC without '[' — just swallow it - } - } else { - output.push(ch); - } - } - - output -} +// ANSI stripping is now in tui_update::strip_ansi() — single canonical implementation. diff --git a/rust/crates/rusty-claude-cli/src/tui_update.rs b/rust/crates/rusty-claude-cli/src/tui_update.rs new file mode 100644 index 00000000..6b665b4c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui_update.rs @@ -0,0 +1,162 @@ +// tui_update.rs — Pure helper functions for TUI mode. +// +// Extracted from main.rs to establish module boundaries. +// LiveCli-dependent code (update_dashboard, run_tui_repl) remains in main.rs +// until LiveCli is made pub(crate). + +use crate::tui::SharedDashboardState; + +/// Strip ANSI escape sequences from text. +/// Handles CSI (ESC [), OSC (ESC ]...BEL/ST), and DCS/ESC sequences. +pub fn strip_ansi(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + match chars.peek() { + Some('[') => { + // CSI sequence: ESC [ + chars.next(); // consume '[' + while let Some(&c) = chars.peek() { + chars.next(); + // Final byte: 0x40-0x7e + if ('\x40'..='\x7e').contains(&c) { + break; + } + } + } + Some(']') => { + // OSC sequence: ESC ] ... (BEL | ESC \) + chars.next(); // consume ']' + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x07' { + break; // BEL terminator + } + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); // consume '\' + break; // ST terminator (ESC \) + } + } + } + Some('P') | Some('_') | Some('^') => { + // DCS, APC, PM — terminated by ESC \ + chars.next(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => { + // Single-char escape (e.g., ESC c) + chars.next(); + } + } + } else { + result.push(ch); + } + } + result +} + +/// Data needed to update the dashboard, extracted from LiveCli +/// so this module doesn't depend on main.rs internals. +pub struct DashboardUpdate<'a> { + pub model: &'a str, + pub permission_mode: &'a str, + pub session_id: Option<&'a str>, + pub turn_count: u32, + pub total_chars: usize, + pub provider: String, + pub provider_url: String, +} + +/// Push extracted CLI/runtime stats into the shared dashboard state. +pub fn update_dashboard_from(state: &SharedDashboardState, update: &DashboardUpdate) { + if let Ok(mut ds) = state.write() { + ds.model = update.model.to_string(); + ds.permission_mode = update.permission_mode.to_string(); + ds.session_id = update.session_id.map(str::to_string); + ds.turn_count = update.turn_count; + // Rough token estimate: ~4 chars per token + ds.input_tokens = (update.total_chars / 4) as u32; + ds.provider = update.provider.clone(); + ds.provider_url = update.provider_url.clone(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_basic_csi() { + assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red"); + } + + #[test] + fn test_strip_osc_hyperlink() { + assert_eq!( + strip_ansi("\x1b]8;;http://example.com\x07link\x1b]8;;\x07"), + "link" + ); + } + + #[test] + fn test_strip_osc_with_st() { + assert_eq!( + strip_ansi("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\"), + "link" + ); + } + + #[test] + fn test_strip_nested_csi() { + assert_eq!(strip_ansi("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red"); + } + + #[test] + fn test_strip_256_color() { + assert_eq!(strip_ansi("\x1b[38;5;196mred256\x1b[0m"), "red256"); + } + + #[test] + fn test_strip_rgb_color() { + assert_eq!(strip_ansi("\x1b[38;2;255;0;0mrgb\x1b[0m"), "rgb"); + } + + #[test] + fn test_strip_cursor_movement() { + assert_eq!(strip_ansi("\x1b[2J\x1b[Hclear"), "clear"); + } + + #[test] + fn test_strip_dcs() { + assert_eq!(strip_ansi("\x1bPq$d\x1b\\"), ""); + } + + #[test] + fn test_strip_no_escapes() { + assert_eq!(strip_ansi("plain text"), "plain text"); + } + + #[test] + fn test_strip_empty() { + assert_eq!(strip_ansi(""), ""); + } + + #[test] + fn test_strip_malformed_no_final() { + assert_eq!(strip_ansi("\x1b[31m"), ""); + } + + #[test] + fn test_strip_mixed() { + let input = "Hello \x1b[1mWorld\x1b[0m \x1b]8;;url\x07Link\x1b]8;;\x07 end"; + assert_eq!(strip_ansi(input), "Hello World Link end"); + } +}