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:
parent
2d62b470f6
commit
294ab2ab66
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue