From c8f106c58c3fd06dbce14edc8b008a581ded61cf Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Feb 2026 09:34:54 +0100 Subject: [PATCH] feat(text): refactored text processing with UTF-16 optimization and ANSI state bitflags - Added `visibleWidth()` function to measure visible text width while excluding ANSI codes. - Optimized text processing from UTF-8 to UTF-16 implementation with direct JsString handling for improved performance. - Refactored ANSI state tracking to use bitflags-based SgrState struct instead of individual boolean fields for more efficient storage. - Introduced ColorCode enum to represent color codes (Basic, Indexed, Rgb) for better type safety. - Added comprehensive test coverage for UTF-16 text processing, ANSI detection, and width calculations. - Updated napi feature from 'napi8' to 'napi10' and added unicode-segmentation dependency. --- Cargo.lock | 1 + crates/pi-natives/Cargo.toml | 3 +- crates/pi-natives/src/text.rs | 1175 +++++++++++++------- packages/coding-agent/src/task/executor.ts | 2 +- packages/natives/CHANGELOG.md | 3 + packages/natives/src/index.ts | 1 + packages/natives/src/native.ts | 1 + packages/natives/src/text/index.ts | 7 + packages/tui/CHANGELOG.md | 7 + packages/tui/bench/width.ts | 187 ++++ packages/tui/src/utils.ts | 48 +- 11 files changed, 982 insertions(+), 453 deletions(-) create mode 100644 packages/tui/bench/width.ts diff --git a/Cargo.lock b/Cargo.lock index 4f4a88dfa..b6fd48b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,6 +805,7 @@ dependencies = [ "napi-derive", "rayon", "syntect", + "unicode-segmentation", "unicode-width", ] diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 590fc7d83..012272726 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["cdylib"] workspace = true [dependencies] -napi = { version = "3", features = ["napi8", "tokio_rt"] } +napi = { version = "3", features = ["napi10", "tokio_rt"] } napi-derive = "3" grep-regex = "0.1" grep-searcher = "0.1" @@ -28,6 +28,7 @@ image = { version = "0.25", default-features = false, features = [ "webp", ] } bstr = "1" +unicode-segmentation = "1.11" unicode-width = "0.2" syntect = { version = "5.3", default-features = false, features = [ "default-syntaxes", diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index 1ef45c692..95185a770 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -1,11 +1,17 @@ -//! ANSI-aware text measurement and slicing utilities. +//! ANSI-aware text measurement and slicing utilities (UTF-16 optimized). +//! +//! Works directly on JS string's internal UTF-16 representation to avoid +//! UTF-8↔UTF-16 conversion overhead. ANSI codes are ASCII and process +//! identically in UTF-16. Text segments convert to UTF-8 only for grapheme +//! iteration (unavoidable - unicode libraries work on &str). -use bstr::ByteSlice; -use napi::{JsString, JsStringUtf8, bindgen_prelude::*}; +use napi::{Env, JsString, JsStringUtf16, bindgen_prelude::*}; use napi_derive::napi; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; const TAB_WIDTH: usize = 3; +const ESC: u16 = 0x1b; /// Result of slicing a line by visible width. #[napi(object)] @@ -31,213 +37,77 @@ pub struct ExtractSegmentsResult { pub after_width: u32, } -struct AnsiCodeTracker { - bold: bool, - dim: bool, - italic: bool, - underline: bool, - blink: bool, - inverse: bool, - hidden: bool, - strikethrough: bool, - fg_color: Option, - bg_color: Option, +#[inline] +fn clamp_u32(value: usize) -> u32 { + value.min(u32::MAX as usize) as u32 } -impl AnsiCodeTracker { - const fn new() -> Self { - Self { - bold: false, - dim: false, - italic: false, - underline: false, - blink: false, - inverse: false, - hidden: false, - strikethrough: false, - fg_color: None, - bg_color: None, - } - } - - fn reset(&mut self) { - self.bold = false; - self.dim = false; - self.italic = false; - self.underline = false; - self.blink = false; - self.inverse = false; - self.hidden = false; - self.strikethrough = false; - self.fg_color = None; - self.bg_color = None; - } - - fn clear(&mut self) { - self.reset(); - } - - fn process(&mut self, ansi_code: &str) { - if !ansi_code.ends_with('m') { - return; - } - - let params = ansi_code - .strip_prefix("\x1b[") - .and_then(|code| code.strip_suffix('m')); - let Some(params) = params else { - return; - }; - - if params.is_empty() || params == "0" { - self.reset(); - return; - } - - let parts: Vec<&str> = params.split(';').collect(); - let mut i = 0; - while i < parts.len() { - let Ok(code) = parts[i].parse::() else { - i += 1; - continue; - }; - - if code == 38 || code == 48 { - if parts.get(i + 1) == Some(&"5") && parts.get(i + 2).is_some() { - let color_code = format!("{};{};{}", parts[i], parts[i + 1], parts[i + 2]); - if code == 38 { - self.fg_color = Some(color_code); - } else { - self.bg_color = Some(color_code); - } - i += 3; - continue; - } else if parts.get(i + 1) == Some(&"2") && parts.get(i + 4).is_some() { - let color_code = format!( - "{};{};{};{};{}", - parts[i], - parts[i + 1], - parts[i + 2], - parts[i + 3], - parts[i + 4], - ); - if code == 38 { - self.fg_color = Some(color_code); - } else { - self.bg_color = Some(color_code); - } - i += 5; - continue; - } - } - - match code { - 0 => self.reset(), - 1 => self.bold = true, - 2 => self.dim = true, - 3 => self.italic = true, - 4 => self.underline = true, - 5 => self.blink = true, - 7 => self.inverse = true, - 8 => self.hidden = true, - 9 => self.strikethrough = true, - 21 => self.bold = false, - 22 => { - self.bold = false; - self.dim = false; - }, - 23 => self.italic = false, - 24 => self.underline = false, - 25 => self.blink = false, - 27 => self.inverse = false, - 28 => self.hidden = false, - 29 => self.strikethrough = false, - 39 => self.fg_color = None, - 49 => self.bg_color = None, - _ => { - if (30..=37).contains(&code) || (90..=97).contains(&code) { - self.fg_color = Some(code.to_string()); - } else if (40..=47).contains(&code) || (100..=107).contains(&code) { - self.bg_color = Some(code.to_string()); - } - }, - } - - i += 1; - } - } - - fn get_active_codes(&self) -> String { - let mut codes = Vec::new(); - if self.bold { - codes.push("1".to_string()); - } - if self.dim { - codes.push("2".to_string()); - } - if self.italic { - codes.push("3".to_string()); - } - if self.underline { - codes.push("4".to_string()); - } - if self.blink { - codes.push("5".to_string()); - } - if self.inverse { - codes.push("7".to_string()); - } - if self.hidden { - codes.push("8".to_string()); - } - if self.strikethrough { - codes.push("9".to_string()); - } - if let Some(color) = &self.fg_color { - codes.push(color.clone()); - } - if let Some(color) = &self.bg_color { - codes.push(color.clone()); - } - - if codes.is_empty() { - return String::new(); - } - - format!("\x1b[{}m", codes.join(";")) +#[inline] +fn ascii_cell_width(b: u16) -> usize { + match b { + 0x09 => TAB_WIDTH, // '\t' + 0x20..=0x7e => 1, // printable ASCII + _ => 0, // control chars, high values } } -fn extract_ansi_code(text: impl AsRef<[u8]>, pos: usize) -> Option { - let bytes = text.as_ref(); - if pos >= bytes.len() || bytes[pos] != 0x1b { - return None; +#[inline] +fn grapheme_width(g: &str) -> usize { + if g == "\t" { + return TAB_WIDTH; } - if pos + 1 >= bytes.len() { - return None; + // Fast path for single ASCII char + let bytes = g.as_bytes(); + if bytes.len() == 1 { + return ascii_cell_width(bytes[0] as u16); } + UnicodeWidthStr::width(g) +} - match bytes[pos + 1] { - b'[' => { +// ============================================================================ +// ANSI Detection (UTF-16) +// ============================================================================ + +/// Find the next ESC (0x1B) in the slice starting at `start`. +#[inline] +fn find_esc(data: &[u16], start: usize) -> Option { + data[start..] + .iter() + .position(|&c| c == ESC) + .map(|i| start + i) +} + +/// Parse an ANSI escape sequence starting at `pos`. +/// Returns the length in u16 units if valid. +#[inline] +fn ansi_len(data: &[u16], pos: usize) -> Option { + if *data.get(pos)? != ESC { + return None; + } + let next = *data.get(pos + 1)?; + + match next { + 0x5b => { + // '[' - CSI sequence: ESC [ ... final-byte (0x40-0x7E) let mut j = pos + 2; - while j < bytes.len() { - match bytes[j] { - b'm' | b'G' | b'K' | b'H' | b'J' => return Some(j + 1 - pos), - _ => j += 1, + while j < data.len() { + let b = data[j]; + if (0x40..=0x7e).contains(&b) { + return Some(j + 1 - pos); } + j += 1; } None }, - b']' => { + 0x5d => { + // ']' - OSC sequence: ESC ] ... BEL or ESC \ let mut j = pos + 2; - while j < bytes.len() { - if bytes[j] == 0x07 { - return Some(j + 1 - pos); + while j < data.len() { + match data[j] { + 0x07 => return Some(j + 1 - pos), // BEL + ESC if data.get(j + 1) == Some(&0x5c) => return Some(j + 2 - pos), // ESC \ + _ => j += 1, } - if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { - return Some(j + 2 - pos); - } - j += 1; } None }, @@ -245,303 +115,717 @@ fn extract_ansi_code(text: impl AsRef<[u8]>, pos: usize) -> Option { } } -fn next_ansi_start(text: impl AsRef<[u8]>, mut pos: usize) -> Option { - let bytes = text.as_ref(); - while pos < bytes.len() { - if bytes[pos] == 0x1b && extract_ansi_code(bytes, pos).is_some() { - return Some(pos); +// ============================================================================ +// Chunk Processing +// ============================================================================ + +/// Check if a UTF-16 slice is pure ASCII (all values < 0x80). +#[inline] +fn is_ascii_u16(chunk: &[u16]) -> bool { + chunk.iter().all(|&c| c < 0x80) +} + +/// Iterate graphemes in a UTF-16 chunk, calling `f(grapheme_str, u16_slice, +/// width)`. Only converts to UTF-8 if non-ASCII. +#[inline] +fn for_each_grapheme(chunk: &[u16], mut f: F) +where + F: FnMut(&str, &[u16], usize) -> bool, // return false to stop +{ + // Convert chunk to String for grapheme iteration + let s = String::from_utf16_lossy(chunk); + let mut u16_pos = 0; + + for grapheme in s.graphemes(true) { + let width = grapheme_width(grapheme); + let u16_len: usize = grapheme.chars().map(|c| c.len_utf16()).sum(); + let grapheme_u16 = &chunk[u16_pos..u16_pos + u16_len]; + + if !f(grapheme, grapheme_u16, width) { + return; } - pos += 1; + u16_pos += u16_len; } - None } -fn grapheme_width(grapheme: &str) -> usize { - if grapheme == "\t" { - return TAB_WIDTH; - } - UnicodeWidthStr::width(grapheme) -} +// ============================================================================ +// Width Calculation +// ============================================================================ -fn clamp_u32(value: usize) -> u32 { - value.min(u32::MAX as usize) as u32 -} +/// Measure visible width, stopping early if it exceeds `limit`. +/// Returns (width_so_far, exceeded_limit). +fn visible_width_up_to(data: &[u16], limit: usize) -> (usize, bool) { + let mut w = 0usize; + let mut i = 0usize; -enum TextInput<'a> { - Utf8(JsStringUtf8<'a>), - Bytes(&'a Uint8Array), -} - -impl<'a> TextInput<'a> { - fn new(value: &'a Either) -> Result { - match value { - Either::A(value) => Ok(Self::Utf8(value.into_utf8()?)), - Either::B(value) => Ok(Self::Bytes(value)), + while i < data.len() { + if data[i] == ESC { + if let Some(len) = ansi_len(data, i) { + i += len; + continue; + } + // Not a valid ANSI sequence, treat as control char (width 0) } + + let next_esc = find_esc(data, i + 1).unwrap_or(data.len()); + let end = if next_esc == i { i + 1 } else { next_esc }; + let chunk = &data[i..end]; + + if is_ascii_u16(chunk) { + for &c in chunk { + w += ascii_cell_width(c); + if w > limit { + return (w, true); + } + } + } else { + for_each_grapheme(chunk, |_, _, gw| { + w += gw; + w <= limit // continue while not exceeded + }); + if w > limit { + return (w, true); + } + } + + i = end; } - fn as_str(&self) -> Result<&str> { - match self { - Self::Utf8(text) => text.as_str(), - Self::Bytes(bytes) => std::str::from_utf8(bytes.as_ref()) - .map_err(|err| Error::from_reason(format!("Invalid UTF-8: {err}"))), - } - } + (w, false) } -impl AsRef<[u8]> for TextInput<'_> { - fn as_ref(&self) -> &[u8] { - match self { - Self::Utf8(text) => text.as_slice(), - Self::Bytes(bytes) => bytes.as_ref(), - } - } +#[inline] +fn visible_width_full(data: &[u16]) -> usize { + visible_width_up_to(data, usize::MAX).0 } -fn visible_width_impl(text: impl AsRef<[u8]>) -> usize { - let text = text.as_ref(); - if text.is_empty() { - return 0; - } +/// Copy a prefix (including ANSI codes) while visible width stays <= +/// `target_width`. Returns (visible_width_copied, saw_any_ansi). +fn push_prefix_with_ansi(text: &[u16], target_width: usize, out: &mut Vec) -> (usize, bool) { + let mut w = 0usize; + let mut i = 0usize; + let mut saw_ansi = false; - // Single-pass: skip ANSI codes, measure graphemes - let mut i = 0; - let mut width = 0; while i < text.len() { - if let Some(len) = extract_ansi_code(text, i) { - i += len; - continue; + if text[i] == ESC { + if let Some(len) = ansi_len(text, i) { + out.extend_from_slice(&text[i..i + len]); + saw_ansi = true; + i += len; + continue; + } } - // Find next ANSI code or end of string - let next_ansi = next_ansi_start(text, i + 1).unwrap_or(text.len()); - for (_, _, grapheme) in text[i..next_ansi].grapheme_indices() { - width += grapheme_width(grapheme); + let next_esc = find_esc(text, i + 1).unwrap_or(text.len()); + let end = if next_esc == i { i + 1 } else { next_esc }; + let chunk = &text[i..end]; + + if is_ascii_u16(chunk) { + for &c in chunk { + let cw = ascii_cell_width(c); + if w + cw > target_width { + return (w, saw_ansi); + } + out.push(c); + w += cw; + } + } else { + let mut stopped = false; + for_each_grapheme(chunk, |_, gu16, gw| { + if w + gw > target_width { + stopped = true; + return false; + } + out.extend_from_slice(gu16); + w += gw; + true + }); + if stopped { + return (w, saw_ansi); + } } - i = next_ansi; + + i = end; } - width + (w, saw_ansi) } +// ============================================================================ +// SGR State Tracker (zero allocations) +// ============================================================================ + +#[derive(Clone, Copy, Default)] +struct SgrState { + flags: u16, + fg: Option, + bg: Option, +} + +#[derive(Clone, Copy)] +enum ColorCode { + Basic(u16), + Indexed { is_fg: bool, idx: u8 }, + Rgb { is_fg: bool, r: u8, g: u8, b: u8 }, +} + +impl Default for ColorCode { + fn default() -> Self { + ColorCode::Basic(39) + } +} + +impl SgrState { + #[inline] + fn reset(&mut self) { + self.flags = 0; + self.fg = None; + self.bg = None; + } + + #[inline] + fn process_ansi(&mut self, code: &[u16]) { + // Only handle CSI ... m + if code.len() < 3 || code[0] != ESC || code[1] != 0x5b || *code.last().unwrap() != 0x6d { + return; + } + let params = &code[2..code.len() - 1]; + self.process_sgr_params(params); + } + + fn process_sgr_params(&mut self, params: &[u16]) { + if params.is_empty() { + self.reset(); + return; + } + + let mut it = ParamIterU16::new(params); + while let Some(p) = it.next_u16() { + match p { + 0 => self.reset(), + 1 => self.flags |= 1 << 0, + 2 => self.flags |= 1 << 1, + 3 => self.flags |= 1 << 2, + 4 => self.flags |= 1 << 3, + 5 => self.flags |= 1 << 4, + 7 => self.flags |= 1 << 5, + 8 => self.flags |= 1 << 6, + 9 => self.flags |= 1 << 7, + + 21 => self.flags &= !(1 << 0), + 22 => self.flags &= !((1 << 0) | (1 << 1)), + 23 => self.flags &= !(1 << 2), + 24 => self.flags &= !(1 << 3), + 25 => self.flags &= !(1 << 4), + 27 => self.flags &= !(1 << 5), + 28 => self.flags &= !(1 << 6), + 29 => self.flags &= !(1 << 7), + + 39 => self.fg = None, + 49 => self.bg = None, + + 30..=37 | 90..=97 => self.fg = Some(ColorCode::Basic(p)), + 40..=47 | 100..=107 => self.bg = Some(ColorCode::Basic(p)), + + 38 | 48 => { + let is_fg = p == 38; + match it.next_u16() { + Some(5) => { + if let Some(n) = it.next_u16() { + let idx = (n & 0xff) as u8; + if is_fg { + self.fg = Some(ColorCode::Indexed { is_fg: true, idx }); + } else { + self.bg = Some(ColorCode::Indexed { is_fg: false, idx }); + } + } + }, + Some(2) => { + let r = it.next_u16().unwrap_or(0) as u8; + let g = it.next_u16().unwrap_or(0) as u8; + let b = it.next_u16().unwrap_or(0) as u8; + if is_fg { + self.fg = Some(ColorCode::Rgb { is_fg: true, r, g, b }); + } else { + self.bg = Some(ColorCode::Rgb { is_fg: false, r, g, b }); + } + }, + _ => {}, + } + }, + + _ => {}, + } + } + } + + fn write_active_codes(&self, out: &mut Vec) { + if self.flags == 0 && self.fg.is_none() && self.bg.is_none() { + return; + } + + out.extend_from_slice(&[ESC, 0x5b]); // ESC [ + let mut first = true; + + macro_rules! push_code { + ($code:expr) => {{ + if !first { + out.push(0x3b); + } // ';' + first = false; + push_u16_decimal(out, $code); + }}; + } + + if (self.flags & (1 << 0)) != 0 { + push_code!(1); + } + if (self.flags & (1 << 1)) != 0 { + push_code!(2); + } + if (self.flags & (1 << 2)) != 0 { + push_code!(3); + } + if (self.flags & (1 << 3)) != 0 { + push_code!(4); + } + if (self.flags & (1 << 4)) != 0 { + push_code!(5); + } + if (self.flags & (1 << 5)) != 0 { + push_code!(7); + } + if (self.flags & (1 << 6)) != 0 { + push_code!(8); + } + if (self.flags & (1 << 7)) != 0 { + push_code!(9); + } + + if let Some(c) = self.fg { + write_color_u16(out, &mut first, c); + } + if let Some(c) = self.bg { + write_color_u16(out, &mut first, c); + } + + out.push(0x6d); // 'm' + } +} + +struct ParamIterU16<'a> { + data: &'a [u16], + i: usize, +} + +impl<'a> ParamIterU16<'a> { + #[inline] + fn new(data: &'a [u16]) -> Self { + Self { data, i: 0 } + } + + #[inline] + fn next_u16(&mut self) -> Option { + if self.i >= self.data.len() { + return None; + } + + let mut n: u16 = 0; + let mut has_digit = false; + + while self.i < self.data.len() && self.data[self.i] != 0x3b { + // ';' + let c = self.data[self.i]; + if (0x30..=0x39).contains(&c) { + // '0'-'9' + has_digit = true; + n = n.saturating_mul(10).saturating_add((c - 0x30) as u16); + } + self.i += 1; + } + + if self.i < self.data.len() && self.data[self.i] == 0x3b { + self.i += 1; + } + + if has_digit { Some(n) } else { Some(0) } + } +} + +#[inline] +fn push_u16_decimal(out: &mut Vec, mut n: u16) { + if n == 0 { + out.push(0x30); // '0' + return; + } + + let mut buf = [0u16; 5]; + let mut i = buf.len(); + + while n > 0 { + i -= 1; + buf[i] = 0x30 + (n % 10) as u16; + n /= 10; + } + + out.extend_from_slice(&buf[i..]); +} + +#[inline] +fn write_color_u16(out: &mut Vec, first: &mut bool, c: ColorCode) { + macro_rules! sep { + () => { + if !*first { + out.push(0x3b); + } + *first = false; + }; + } + + match c { + ColorCode::Basic(code) => { + sep!(); + push_u16_decimal(out, code); + }, + ColorCode::Indexed { is_fg, idx } => { + sep!(); + push_u16_decimal(out, if is_fg { 38 } else { 48 }); + out.push(0x3b); + push_u16_decimal(out, 5); + out.push(0x3b); + push_u16_decimal(out, idx as u16); + }, + ColorCode::Rgb { is_fg, r, g, b } => { + sep!(); + push_u16_decimal(out, if is_fg { 38 } else { 48 }); + out.push(0x3b); + push_u16_decimal(out, 2); + out.push(0x3b); + push_u16_decimal(out, r as u16); + out.push(0x3b); + push_u16_decimal(out, g as u16); + out.push(0x3b); + push_u16_decimal(out, b as u16); + }, + } +} + +// ============================================================================ +// JsString Helpers +// ============================================================================ + +#[inline] +fn js_string_from_u16<'e>(env: &'e Env, data: &[u16]) -> Result> { + unsafe { env.create_string_utf16(data) } +} + +/// Convert Vec to String for struct fields. +/// Uses from_utf16_lossy which handles surrogates gracefully. +#[inline] +fn u16_to_string(data: Vec) -> String { + String::from_utf16_lossy(&data) +} + +// ============================================================================ +// truncateToWidth +// ============================================================================ + /// Truncate text to a visible width, preserving ANSI codes. #[napi(js_name = "truncateToWidth")] -pub fn truncate_to_width( - text: Either, +pub fn truncate_to_width<'e>( + env: &'e Env, + text: JsString, max_width: u32, - ellipsis: Either, + ellipsis: JsString, pad: bool, -) -> Result { - let text = TextInput::new(&text)?; - let text = text.as_str()?; - let ellipsis = TextInput::new(&ellipsis)?; - let ellipsis = ellipsis.as_str()?; +) -> Result> { + let text_u16 = text.into_utf16()?; + let text_data = text_u16.as_slice(); let max_width = max_width as usize; - let text_visible_width = visible_width_impl(text); - if text_visible_width <= max_width { - if pad { - return Ok(format!("{}{}", text, " ".repeat(max_width - text_visible_width))); + + // 1) Quick width check with early exit + let (w, exceeded) = visible_width_up_to(text_data, max_width); + + if !exceeded { + if !pad { + // Best case: return original string (but we consumed it with into_utf16) + // Recreate from the same data - this is still cheaper than full processing + return js_string_from_u16(&env, text_data); } - return Ok(text.to_owned()); + + // Padding required + let pad_spaces = max_width.saturating_sub(w); + let mut out = Vec::with_capacity(text_data.len() + pad_spaces); + out.extend_from_slice(text_data); + out.resize(out.len() + pad_spaces, 0x20); // ' ' + return js_string_from_u16(&env, &out); } - let ellipsis_width = visible_width_impl(ellipsis); - let target_width = max_width.saturating_sub(ellipsis_width); - if target_width == 0 { - return Ok(ellipsis - .as_bytes() - .grapheme_indices() - .take(max_width) - .map(|(_, _, g)| g) - .collect()); + // 2) Truncation needed - now measure ellipsis (lazy) + let ellipsis_u16 = ellipsis.into_utf16()?; + let ellipsis_data = ellipsis_u16.as_slice(); + let ellipsis_w = visible_width_full(ellipsis_data); + let target_w = max_width.saturating_sub(ellipsis_w); + + // If ellipsis alone doesn't fit + if target_w == 0 { + let mut out = Vec::with_capacity(ellipsis_data.len().min(32)); + let _ = push_prefix_with_ansi(ellipsis_data, max_width, &mut out); + return js_string_from_u16(&env, &out); } - // Streaming: walk string once, copy ANSI codes through, copy graphemes until - // target - let mut out = String::with_capacity(text.len().min(max_width * 4)); - let mut width = 0usize; - let mut i = 0; + // 3) Build truncated prefix + reset + ellipsis + let mut out = Vec::with_capacity(text_data.len().min(max_width * 2) + ellipsis_data.len() + 8); + let (prefix_w, saw_ansi) = push_prefix_with_ansi(text_data, target_w, &mut out); - while i < text.len() { - // Pass through ANSI codes unchanged - if let Some(len) = extract_ansi_code(text, i) { - out.push_str(&text[i..i + len]); - i += len; - continue; - } - - // Copy graphemes until we hit target width or next ANSI code - let next_ansi = next_ansi_start(text, i + 1).unwrap_or(text.len()); - for (_, _, grapheme) in text.as_bytes()[i..next_ansi].grapheme_indices() { - let w = grapheme_width(grapheme); - if width + w > target_width { - // Hit limit, stop copying - i = text.len(); // Signal outer loop to exit - break; - } - out.push_str(grapheme); - width += w; - } - if width >= target_width || i >= text.len() { - break; - } - i = next_ansi; + if saw_ansi { + out.extend_from_slice(&[ESC, 0x5b, 0x30, 0x6d]); // \x1b[0m } - - out.push_str("\x1b[0m"); - out.push_str(ellipsis); + out.extend_from_slice(ellipsis_data); if pad { - let out_width = width + ellipsis_width; // We know the width without re-scanning - if out_width < max_width { - out.push_str(&" ".repeat(max_width - out_width)); + let out_w = prefix_w + ellipsis_w; + if out_w < max_width { + out.resize(out.len() + max_width - out_w, 0x20); } } - Ok(out) + js_string_from_u16(&env, &out) } +// ============================================================================ +// sliceWithWidth +// ============================================================================ + fn slice_with_width_impl( - line: impl AsRef<[u8]>, + line: &[u16], start_col: usize, length: usize, strict: bool, -) -> SliceResult { - let line = line.as_ref(); +) -> (Vec, usize) { let end_col = start_col + length; - let mut result = String::new(); - let mut result_width = 0; - let mut current_col = 0; - let mut i = 0; - let mut pending_ansi = String::new(); + let mut out = Vec::::new(); + let mut out_width = 0usize; + let mut current_col = 0usize; + + // Store ANSI ranges (offset, len) that occurred before start_col + let mut pending_ansi: Vec<(usize, usize)> = Vec::new(); + + let mut i = 0usize; while i < line.len() { - if let Some(len) = extract_ansi_code(line, i) { - let code = &line[i..i + len]; - // SAFETY: we know the code is valid UTF-8 - let code = unsafe { std::str::from_utf8_unchecked(code) }; - - if current_col >= start_col && current_col < end_col { - result.push_str(code); - } else if current_col < start_col { - pending_ansi.push_str(code); - } - i += len; - continue; - } - - let next_ansi = next_ansi_start(line, i); - let end = next_ansi.unwrap_or(line.len()); - for (_, _, grapheme) in line[i..end].grapheme_indices() { - let width = grapheme_width(grapheme); - let in_range = current_col >= start_col && current_col < end_col; - let fits = !strict || current_col + width <= end_col; - - if in_range && fits { - if !pending_ansi.is_empty() { - result.push_str(&pending_ansi); - pending_ansi.clear(); + if line[i] == ESC { + if let Some(len) = ansi_len(line, i) { + if current_col >= start_col && current_col < end_col { + out.extend_from_slice(&line[i..i + len]); + } else if current_col < start_col { + pending_ansi.push((i, len)); } - result.push_str(grapheme); - result_width += width; - } - - current_col += width; - if current_col >= end_col { - break; + i += len; + continue; } } + + let next_esc = find_esc(line, i + 1).unwrap_or(line.len()); + let end = if next_esc == i { i + 1 } else { next_esc }; + let chunk = &line[i..end]; + + if is_ascii_u16(chunk) { + for (idx, &c) in chunk.iter().enumerate() { + let w = ascii_cell_width(c); + let in_range = current_col >= start_col && current_col < end_col; + let fits = !strict || current_col + w <= end_col; + + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.push(c); + out_width += w; + } + + current_col += w; + if current_col >= end_col { + break; + } + } + } else { + for_each_grapheme(chunk, |_, gu16, gw| { + let in_range = current_col >= start_col && current_col < end_col; + let fits = !strict || current_col + gw <= end_col; + + if in_range && fits { + if !pending_ansi.is_empty() { + for &(p, l) in &pending_ansi { + out.extend_from_slice(&line[p..p + l]); + } + pending_ansi.clear(); + } + out.extend_from_slice(gu16); + out_width += gw; + } + + current_col += gw; + current_col < end_col // continue while not past end + }); + } + i = end; if current_col >= end_col { break; } } - SliceResult { text: result, width: clamp_u32(result_width) } + (out, out_width) } /// Slice a range of visible columns from a line. #[napi(js_name = "sliceWithWidth")] pub fn slice_with_width( - line: Either, + line: JsString, start_col: u32, length: u32, strict: bool, ) -> Result { - let line = TextInput::new(&line)?; - Ok(slice_with_width_impl(line, start_col as usize, length as usize, strict)) + let line_u16 = line.into_utf16()?; + let line_data = line_u16.as_slice(); + + let (out, out_w) = slice_with_width_impl(line_data, start_col as usize, length as usize, strict); + + Ok(SliceResult { text: u16_to_string(out), width: clamp_u32(out_w) }) } +// ============================================================================ +// extractSegments +// ============================================================================ + fn extract_segments_impl( - line: impl AsRef<[u8]>, + line: &[u16], before_end: usize, after_start: usize, after_len: usize, strict_after: bool, -) -> ExtractSegmentsResult { - let mut before = String::new(); - let mut before_width = 0; - let mut after = String::new(); - let mut after_width = 0; - let mut current_col = 0; - let mut i = 0; - let mut pending_ansi_before = String::new(); - let mut after_started = false; +) -> (Vec, usize, Vec, usize) { let after_end = after_start + after_len; - let mut tracker = AnsiCodeTracker::new(); - tracker.clear(); - let line = line.as_ref(); + let mut before = Vec::::new(); + let mut before_width = 0usize; + let mut after = Vec::::new(); + let mut after_width = 0usize; + + let mut pending_before_ansi: Vec<(usize, usize)> = Vec::new(); + let mut tracker = SgrState::default(); + tracker.reset(); + let mut after_started = false; + + let mut current_col = 0usize; + let mut i = 0usize; + while i < line.len() { - if let Some(len) = extract_ansi_code(line, i) { - let code = &line[i..i + len]; - // SAFETY: we know the code is valid UTF-8 - let code = unsafe { std::str::from_utf8_unchecked(code) }; - tracker.process(code); - if current_col < before_end { - pending_ansi_before.push_str(code); - } else if current_col >= after_start && current_col < after_end && after_started { - after.push_str(code); + if line[i] == ESC { + if let Some(len) = ansi_len(line, i) { + let code = &line[i..i + len]; + tracker.process_ansi(code); + + if current_col < before_end { + pending_before_ansi.push((i, len)); + } else if current_col >= after_start && current_col < after_end && after_started { + after.extend_from_slice(code); + } + + i += len; + continue; } - i += len; - continue; } - let next_ansi = next_ansi_start(line, i); - let end = next_ansi.unwrap_or(line.len()); - for (_, _, grapheme) in line[i..end].grapheme_indices() { - let width = grapheme_width(grapheme); + let next_esc = find_esc(line, i + 1).unwrap_or(line.len()); + let end = if next_esc == i { i + 1 } else { next_esc }; + let chunk = &line[i..end]; - if current_col < before_end { - if !pending_ansi_before.is_empty() { - before.push_str(&pending_ansi_before); - pending_ansi_before.clear(); - } - before.push_str(grapheme); - before_width += width; - } else if current_col >= after_start && current_col < after_end { - let fits = !strict_after || current_col + width <= after_end; - if fits { - if !after_started { - after.push_str(&tracker.get_active_codes()); - after_started = true; + if is_ascii_u16(chunk) { + for &c in chunk { + let w = ascii_cell_width(c); + + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); } - after.push_str(grapheme); - after_width += width; + before.push(c); + before_width += w; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + w <= after_end; + if fits { + if !after_started { + tracker.write_active_codes(&mut after); + after_started = true; + } + after.push(c); + after_width += w; + } + } + + current_col += w; + + let done = if after_len == 0 { + current_col >= before_end + } else { + current_col >= after_end + }; + if done { + break; } } + } else { + let mut should_break = false; + for_each_grapheme(chunk, |_, gu16, gw| { + if current_col < before_end { + if !pending_before_ansi.is_empty() { + for &(p, l) in &pending_before_ansi { + before.extend_from_slice(&line[p..p + l]); + } + pending_before_ansi.clear(); + } + before.extend_from_slice(gu16); + before_width += gw; + } else if current_col >= after_start && current_col < after_end { + let fits = !strict_after || current_col + gw <= after_end; + if fits { + if !after_started { + tracker.write_active_codes(&mut after); + after_started = true; + } + after.extend_from_slice(gu16); + after_width += gw; + } + } - current_col += width; - let done = if after_len == 0 { - current_col >= before_end - } else { - current_col >= after_end - }; - if done { + current_col += gw; + + let done = if after_len == 0 { + current_col >= before_end + } else { + current_col >= after_end + }; + if done { + should_break = true; + return false; + } + true + }); + + if should_break { break; } } + i = end; + let done = if after_len == 0 { current_col >= before_end } else { @@ -552,29 +836,90 @@ fn extract_segments_impl( } } - ExtractSegmentsResult { - before, - before_width: clamp_u32(before_width), - after, - after_width: clamp_u32(after_width), - } + (before, before_width, after, after_width) } /// Extract the before/after slices around an overlay region. #[napi(js_name = "extractSegments")] pub fn extract_segments( - line: Either, + line: JsString, before_end: u32, after_start: u32, after_len: u32, strict_after: bool, ) -> Result { - let line = TextInput::new(&line)?; - Ok(extract_segments_impl( - line, + let line_u16 = line.into_utf16()?; + let line_data = line_u16.as_slice(); + + let (before_out, before_w, after_out, after_w) = extract_segments_impl( + line_data, before_end as usize, after_start as usize, after_len as usize, strict_after, - )) + ); + + Ok(ExtractSegmentsResult { + before: u16_to_string(before_out), + before_width: clamp_u32(before_w), + after: u16_to_string(after_out), + after_width: clamp_u32(after_w), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn to_u16(s: &str) -> Vec { + s.encode_utf16().collect() + } + + #[test] + fn test_visible_width() { + assert_eq!(visible_width_full(&to_u16("hello")), 5); + assert_eq!(visible_width_full(&to_u16("\x1b[31mhello\x1b[0m")), 5); + assert_eq!(visible_width_full(&to_u16("\x1b[38;5;196mred\x1b[0m")), 3); + assert_eq!(visible_width_full(&to_u16("a\tb")), 1 + TAB_WIDTH + 1); + } + + #[test] + fn test_ansi_detection() { + let data = to_u16("\x1b[31mred\x1b[0m"); + assert_eq!(ansi_len(&data, 0), Some(5)); // \x1b[31m + assert_eq!(ansi_len(&data, 8), Some(4)); // \x1b[0m + } + + #[test] + fn test_slice_basic() { + let data = to_u16("hello world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false); + assert_eq!(String::from_utf16_lossy(&out), "hello"); + assert_eq!(width, 5); + } + + #[test] + fn test_slice_with_ansi() { + let data = to_u16("\x1b[31mhello\x1b[0m world"); + let (out, width) = slice_with_width_impl(&data, 0, 5, false); + assert_eq!(String::from_utf16_lossy(&out), "\x1b[31mhello\x1b[0m"); + assert_eq!(width, 5); + } + + #[test] + fn test_ascii_fast_path() { + let ascii = to_u16("hello world 12345"); + assert!(is_ascii_u16(&ascii)); + + let non_ascii = to_u16("hello 世界"); + assert!(!is_ascii_u16(&non_ascii)); + } + + #[test] + fn test_early_exit() { + let data = to_u16("a]b".repeat(1000).as_str()); + let (w, exceeded) = visible_width_up_to(&data, 10); + assert!(exceeded); + assert!(w > 10); + } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index c1c3a7dbc..3b2fabe6f 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -135,7 +135,7 @@ function resolveModelOverride( ): { model?: Model; thinkingLevel?: ThinkingLevel } { if (modelPatterns.length === 0) return {}; const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() }; - const roles = settings?.serialize().modelRoles as Record | undefined; + const roles = settings?.getGroup("modelRoles"); for (const pattern of modelPatterns) { const normalized = pattern.trim().toLowerCase(); if (!normalized || DEFAULT_MODEL_ALIASES.has(normalized)) { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 02e7be94a..8f8b066cb 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added `visibleWidth()` function to measure the visible width of text, excluding ANSI codes ## [9.6.1] - 2026-02-01 ### Added diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index be0cf1bd4..10bde1f85 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -75,6 +75,7 @@ export { type SliceWithWidthResult, sliceWithWidth, truncateToWidth, + visibleWidth, } from "./text/index"; // ============================================================================= diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 27da49915..a21df5f66 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -57,6 +57,7 @@ export interface NativeBindings { PhotonImage: NativePhotonImageConstructor; truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput, pad: boolean): string; sliceWithWidth(line: TextInput, startCol: number, length: number, strict: boolean): SliceWithWidthResult; + visibleWidth(text: TextInput): number; extractSegments( line: TextInput, beforeEnd: number, diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts index 71641a4aa..4cdf5c380 100644 --- a/packages/natives/src/text/index.ts +++ b/packages/natives/src/text/index.ts @@ -25,6 +25,13 @@ export function truncateToWidth(text: TextInput, maxWidth: number, ellipsis: Tex return native.truncateToWidth(text, maxWidth, ellipsis, pad); } +/** + * Measure the visible width of text (excluding ANSI codes). + */ +export function visibleWidth(text: TextInput): number { + return native.visibleWidth(text); +} + /** * Slice a range of visible columns from a line. */ diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4dc137ccc..80b896f5d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Changed + +- Refactored `visibleWidth` function to use caching wrapper around new `visibleWidthRaw` implementation for improved performance + +### Removed + +- Removed `extractAnsiCode` function from public API ## [9.6.1] - 2026-02-01 ### Changed diff --git a/packages/tui/bench/width.ts b/packages/tui/bench/width.ts new file mode 100644 index 000000000..cf2f138ef --- /dev/null +++ b/packages/tui/bench/width.ts @@ -0,0 +1,187 @@ +/** + * Benchmark: native visibleWidth vs Bun.stringWidth vs hybrid implementation + * + * Run: bun packages/tui/bench/visible-width.ts + */ +import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; +import { visibleWidthRaw as hybridVisibleWidth } from "../src/utils"; + +const ITERATIONS = 10_000; +const WARMUP = 500; + +// Test cases covering different scenarios +const samples = { + // Pure ASCII - different lengths + ascii_short: "hello", + ascii_medium: "hello world this is a plain ASCII string with some words", + ascii_long: "a".repeat(500), + + // ANSI escape codes + ansi_simple: "\x1b[31mred\x1b[0m", + ansi_complex: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with more \x1b[1;33;44mstyles\x1b[0m", + ansi_nested: "\x1b[1m\x1b[31m\x1b[4mbold red underline\x1b[0m normal \x1b[32mgreen\x1b[0m", + + // OSC 8 hyperlinks + links: "prefix \x1b]8;;https://example.com\x07link text\x1b]8;;\x07 suffix", + links_multiple: + "Click \x1b]8;;https://a.com\x07here\x1b]8;;\x07 or \x1b]8;;https://b.com\x07there\x1b]8;;\x07 for info", + + // Wide characters (CJK) + cjk_short: "日本語", + cjk_medium: "日本語のテキストとemoji", + cjk_long: "日本語のテキストと中文字符和한국어문자混合在一起形成很长的字符串", + + // Emoji + emoji_simple: "👋🌍", + emoji_complex: "Hello 👨‍👩‍👧‍👦 family! 🚀✨🎉 Let's go! 🇺🇸🏳️‍🌈", + emoji_zwj: "👨‍💻👩‍🔬👨‍👩‍👧‍👦", // ZWJ sequences + + // Mixed content + mixed_short: "Hello 世界 🌍", + mixed_medium: "\x1b[32mStatus:\x1b[0m 成功 ✓ (took 42ms)", + mixed_long: + "\x1b[1;34m[INFO]\x1b[0m Processing 日本語テキスト with emoji 🚀 and \x1b]8;;https://example.com\x07links\x1b]8;;\x07 完了", + + // Edge cases + tabs: "col1\tcol2\tcol3\tcol4", + empty: "", + newlines: "line1\nline2\nline3", + control_chars: "text\x00with\x01control\x02chars", +}; + +// Bun.stringWidth with ANSI stripping (what hybrid uses for short strings) +function bunStringWidth(str: string): number { + if (str.length === 0) return 0; + + let clean = str; + if (str.includes("\t")) { + clean = clean.replace(/\t/g, " "); + } + if (clean.includes("\x1b")) { + clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, ""); + clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); + } + return Bun.stringWidth(clean); +} + +interface BenchResult { + name: string; + totalMs: number; + perOpUs: number; +} + +function bench(name: string, fn: () => void): BenchResult { + // Warmup + for (let i = 0; i < WARMUP; i++) fn(); + + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + fn(); + } + const totalMs = performance.now() - start; + const perOpUs = (totalMs / ITERATIONS) * 1000; + + return { name, totalMs, perOpUs }; +} + +function formatResult(r: BenchResult, baseline?: BenchResult): string { + const perOp = r.perOpUs.toFixed(3); + if (baseline && baseline !== r) { + const ratio = r.perOpUs / baseline.perOpUs; + const indicator = ratio < 1 ? "faster" : "slower"; + return `${r.name.padEnd(20)} ${r.totalMs.toFixed(2).padStart(8)}ms ${perOp.padStart(8)}µs/op ${ratio.toFixed(2)}x ${indicator}`; + } + return `${r.name.padEnd(20)} ${r.totalMs.toFixed(2).padStart(8)}ms ${perOp.padStart(8)}µs/op (baseline)`; +} + +console.log(`\n${"=".repeat(80)}`); +console.log(`visibleWidth benchmark: ${ITERATIONS.toLocaleString()} iterations, ${WARMUP} warmup`); +console.log(`${"=".repeat(80)}\n`); + +for (const [sampleName, sample] of Object.entries(samples)) { + console.log(`\n--- ${sampleName} (len=${sample.length}) ---`); + if (sample.length > 0 && sample.length < 80) { + // Show sample for short strings (escape non-printable) + const display = sample.replace(/\x1b/g, "\\e").replace(/\x07/g, "\\a"); + console.log(` "${display}"`); + } + + const results: BenchResult[] = []; + + results.push( + bench("native", () => { + nativeVisibleWidth(sample); + }), + ); + + results.push( + bench("bun+strip", () => { + bunStringWidth(sample); + }), + ); + + results.push( + bench("hybrid", () => { + hybridVisibleWidth(sample); + }), + ); + + // Find fastest as baseline + const baseline = results.reduce((a, b) => (a.perOpUs < b.perOpUs ? a : b)); + + console.log(); + for (const r of results) { + console.log(` ${formatResult(r, baseline)}`); + } + + // Verify correctness + const nativeResult = nativeVisibleWidth(sample); + const bunResult = bunStringWidth(sample); + const hybridResult = hybridVisibleWidth(sample); + + if (nativeResult !== hybridResult) { + console.log(` ⚠️ MISMATCH: native=${nativeResult}, hybrid=${hybridResult}`); + } + if (bunResult !== hybridResult && !sample.includes("\x00")) { + // Control chars can differ + console.log(` ⚠️ MISMATCH: bun=${bunResult}, hybrid=${hybridResult}`); + } +} + +console.log(`\n${"=".repeat(80)}`); +console.log("Summary"); +console.log(`${"=".repeat(80)}\n`); + +// Aggregate by category +const categories = { + ascii: ["ascii_short", "ascii_medium", "ascii_long"], + ansi: ["ansi_simple", "ansi_complex", "ansi_nested"], + links: ["links", "links_multiple"], + cjk: ["cjk_short", "cjk_medium", "cjk_long"], + emoji: ["emoji_simple", "emoji_complex", "emoji_zwj"], + mixed: ["mixed_short", "mixed_medium", "mixed_long"], +}; + +for (const [category, sampleNames] of Object.entries(categories)) { + const categoryResults = { native: 0, bun: 0, hybrid: 0 }; + + for (const name of sampleNames) { + const sample = samples[name as keyof typeof samples]; + + const nativeTime = bench("", () => nativeVisibleWidth(sample)).perOpUs; + const bunTime = bench("", () => bunStringWidth(sample)).perOpUs; + const hybridTime = bench("", () => hybridVisibleWidth(sample)).perOpUs; + + categoryResults.native += nativeTime; + categoryResults.bun += bunTime; + categoryResults.hybrid += hybridTime; + } + + const fastest = Math.min(categoryResults.native, categoryResults.bun, categoryResults.hybrid); + const winner = + fastest === categoryResults.native ? "native" : fastest === categoryResults.bun ? "bun+strip" : "hybrid"; + + console.log( + `${category.padEnd(10)} native: ${categoryResults.native.toFixed(1)}µs bun: ${categoryResults.bun.toFixed(1)}µs hybrid: ${categoryResults.hybrid.toFixed(1)}µs → ${winner} wins`, + ); +} diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 5ca551cc9..7b9e764ee 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -33,7 +33,7 @@ const widthCache = new Map(); /** * Calculate the visible width of a string in terminal columns. */ -export function visibleWidth(str: string): number { +export function visibleWidthRaw(str: string): number { if (str.length === 0) { return 0; } @@ -52,6 +52,16 @@ export function visibleWidth(str: string): number { if (isPureAscii) { return str.length + tabLength; } + return Bun.stringWidth(str) + tabLength; +} + +/** + * Calculate the visible width of a string in terminal columns. + */ +export function visibleWidth(str: string): number { + if (str.length === 0) { + return 0; + } // Check cache const cached = widthCache.get(str); @@ -59,8 +69,7 @@ export function visibleWidth(str: string): number { return cached; } - // Cache result - const width = Bun.stringWidth(str) + tabLength; + const width = visibleWidthRaw(str); if (widthCache.size >= WIDTH_CACHE_SIZE) { const firstKey = widthCache.keys().next().value; if (firstKey !== undefined) { @@ -72,39 +81,6 @@ export function visibleWidth(str: string): number { return width; } -/** - * Extract ANSI escape sequences from a string at the given position. - */ -export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { - if (pos >= str.length || str[pos] !== "\x1b") return null; - - const next = str[pos + 1]; - - // CSI sequence: ESC [ ... m/G/K/H/J - if (next === "[") { - let j = pos + 2; - while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; - if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - return null; - } - - // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) - // Used for hyperlinks (OSC 8), window titles, etc. - if (next === "]") { - let j = pos + 2; - while (j < str.length) { - if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - if (str[j] === "\x1b" && str[j + 1] === "\\") { - return { code: str.substring(pos, j + 2), length: j + 2 - pos }; - } - j++; - } - return null; - } - - return null; -} - const WRAP_OPTIONS = { wordWrap: true, hard: true, trim: false } as const; /**