refactor(tui): extract ANSI stripper and dashboard update to tui_update module

- Create src/tui_update.rs with canonical strip_ansi() (handles CSI, OSC, DCS)
- Add DashboardUpdate struct to decouple from private LiveCli
- Remove duplicate strip_ansi_escapes from tui.rs and strip_ansi from main.rs
- update_dashboard() now delegates to tui_update::update_dashboard_from()
- 12 unit tests for ANSI stripping (CSI, OSC/BEL, OSC/ST, DCS, 256-color, RGB)

Sprint 0, Story S0-1. Pure extraction — no behavioral changes.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 18:37:39 -05:00
parent 2d62b470f6
commit 294ab2ab66
3 changed files with 195 additions and 85 deletions

View File

@ -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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
/// 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::<usize>()
}).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::<usize>()
}).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 {

View File

@ -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 [ ... <final byte>
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.

View File

@ -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 [ <params> <intermediate> <final>
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");
}
}