diff --git a/Cargo.lock b/Cargo.lock index 7ea6abb14..68fed26b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,9 +124,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "convert_case" -version = "0.6.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -167,14 +167,35 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "ctor" -version = "0.2.9" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" dependencies = [ - "quote", - "syn", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "either" version = "1.15.0" @@ -263,6 +284,95 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -435,9 +545,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" -version = "0.8.9" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", "windows-link", @@ -533,15 +643,17 @@ dependencies = [ [[package]] name = "napi" -version = "2.16.17" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +checksum = "909805cbad4d569e69b80e101290fe72e92b9742ba9e333b0c1e83b22fb7447b" dependencies = [ "bitflags", "ctor", - "napi-derive", + "futures", + "napi-build", "napi-sys", - "once_cell", + "nohash-hasher", + "rustc-hash", "tokio", ] @@ -553,12 +665,12 @@ checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" [[package]] name = "napi-derive" -version = "2.16.13" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +checksum = "04ba21bbdf40b33496b4ee6eadfc64d17a6a6cde57cd31549117b0882d1fef86" dependencies = [ - "cfg-if", "convert_case", + "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -567,24 +679,22 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "1.0.75" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +checksum = "e9a63791e230572c3218a7acd86ca0a0529fc64294bcbea567cf906d7b04e077" dependencies = [ "convert_case", - "once_cell", "proc-macro2", "quote", - "regex", "semver", "syn", ] [[package]] name = "napi-sys" -version = "2.4.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" dependencies = [ "libloading", ] @@ -595,6 +705,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-traits" version = "0.2.19" @@ -676,6 +792,7 @@ dependencies = [ name = "pi-natives" version = "9.6.0" dependencies = [ + "bstr", "globset", "grep-matcher", "grep-regex", @@ -688,7 +805,6 @@ dependencies = [ "napi-derive", "rayon", "syntect", - "unicode-segmentation", "unicode-width", ] @@ -698,6 +814,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "png" version = "0.18.0" @@ -814,6 +936,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "same-file" version = "1.0.6" @@ -876,6 +1004,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 4eabc2dc9..590fc7d83 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -13,18 +13,27 @@ crate-type = ["cdylib"] workspace = true [dependencies] -napi = { version = "2", features = ["napi8", "tokio_rt"] } -napi-derive = "2" -grep-regex = "0.1.14" -grep-searcher = "0.1.16" -grep-matcher = "0.1.8" +napi = { version = "3", features = ["napi8", "tokio_rt"] } +napi-derive = "3" +grep-regex = "0.1" +grep-searcher = "0.1" +grep-matcher = "0.1" globset = "0.4" ignore = "0.4" rayon = "1.10" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } -unicode-segmentation = "1.11" +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "gif", + "webp", +] } +bstr = "1" unicode-width = "0.2" -syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } +syntect = { version = "5.3", default-features = false, features = [ + "default-syntaxes", + "default-themes", + "regex-fancy", +] } html-to-markdown-rs = { version = "2.24", default-features = false } [build-dependencies] diff --git a/crates/pi-natives/src/find.rs b/crates/pi-natives/src/find.rs index ac79939b3..f21fe8183 100644 --- a/crates/pi-natives/src/find.rs +++ b/crates/pi-natives/src/find.rs @@ -19,7 +19,7 @@ use globset::{Glob, GlobSet, GlobSetBuilder}; use ignore::WalkBuilder; use napi::{ bindgen_prelude::*, - threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio::task, }; use napi_derive::napi; @@ -181,7 +181,7 @@ struct FindConfig { fn run_find( config: FindConfig, - on_match: Option<&ThreadsafeFunction>, + on_match: Option<&ThreadsafeFunction>, cancelled: &AtomicBool, ) -> Result { let FindConfig { @@ -255,7 +255,7 @@ fn run_find( // Call streaming callback if provided if let Some(callback) = on_match { - callback.call(found.clone(), ThreadsafeFunctionCallMode::NonBlocking); + callback.call(Ok(found.clone()), ThreadsafeFunctionCallMode::NonBlocking); } matches.push(found); @@ -291,7 +291,7 @@ fn run_find( pub async fn find( options: FindOptions, #[napi(ts_arg_type = "((match: FindMatch) => void) | undefined | null")] on_match: Option< - ThreadsafeFunction, + ThreadsafeFunction, >, ) -> Result { let FindOptions { pattern, path, file_type, hidden, max_results, gitignore, sort_by_mtime } = diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index 3a3413f14..f7b57bdc5 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -24,7 +24,7 @@ use ignore::WalkBuilder; use napi::{ JsString, bindgen_prelude::*, - threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio::task, }; use napi_derive::napi; @@ -751,7 +751,7 @@ fn search_sync(content: &[u8], options: SearchOptions) -> SearchResult { fn grep_sync( options: GrepOptions, - on_match: Option<&ThreadsafeFunction>, + on_match: Option<&ThreadsafeFunction>, ) -> Result { let search_path = resolve_search_path(&options.path)?; let metadata = std::fs::metadata(&search_path) @@ -877,7 +877,7 @@ fn grep_sync( for matched in result.matches { let grep_match = to_grep_match(&result.relative_path, matched); if let Some(callback) = on_match { - callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking); + callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking); } matches.push(grep_match); } @@ -893,7 +893,7 @@ fn grep_sync( match_count: Some(clamp_u32(result.match_count)), }; if let Some(callback) = on_match { - callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking); + callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking); } matches.push(grep_match); }, @@ -923,7 +923,7 @@ fn grep_sync( // Fire callbacks for sequential search results if let Some(callback) = on_match { for grep_match in &matches { - callback.call(grep_match.clone(), ThreadsafeFunctionCallMode::NonBlocking); + callback.call(Ok(grep_match.clone()), ThreadsafeFunctionCallMode::NonBlocking); } } @@ -1000,7 +1000,7 @@ pub fn has_match( pub async fn grep( options: GrepOptions, #[napi(ts_arg_type = "((match: GrepMatch) => void) | undefined | null")] on_match: Option< - ThreadsafeFunction, + ThreadsafeFunction, >, ) -> Result { task::spawn_blocking(move || grep_sync(options, on_match.as_ref())) diff --git a/crates/pi-natives/src/keys.rs b/crates/pi-natives/src/keys.rs new file mode 100644 index 000000000..dc5a99c8c --- /dev/null +++ b/crates/pi-natives/src/keys.rs @@ -0,0 +1,253 @@ +//! Kitty keyboard sequence matching utilities. +//! +//! # Overview +//! Parses Kitty keyboard protocol sequences and matches codepoints plus +//! modifiers. +//! +//! # Example +//! ```ignore +//! // JS: native.matchesKittySequence("\x1b[65;5u", 65, 4) +//! ``` + +use napi_derive::napi; + +const LOCK_MASK: u32 = 64 + 128; + +const ARROW_UP: i32 = -1; +const ARROW_DOWN: i32 = -2; +const ARROW_RIGHT: i32 = -3; +const ARROW_LEFT: i32 = -4; + +const FUNC_DELETE: i32 = -10; +const FUNC_INSERT: i32 = -11; +const FUNC_PAGE_UP: i32 = -12; +const FUNC_PAGE_DOWN: i32 = -13; +const FUNC_HOME: i32 = -14; +const FUNC_END: i32 = -15; + +struct ParsedKittySequence { + codepoint: i32, + base_layout_key: Option, + modifier: u32, +} + +/// Matches Kitty protocol keyboard sequences against a codepoint and modifier. +/// +/// # Errors +/// Returns `false` when the input is not a Kitty sequence or does not match. +#[napi(js_name = "matchesKittySequence")] +pub fn matches_kitty_sequence( + data: String, + expected_codepoint: i32, + expected_modifier: u32, +) -> bool { + let Some(parsed) = parse_kitty_sequence(&data) else { + return false; + }; + + let actual_mod = parsed.modifier & !LOCK_MASK; + let expected_mod = expected_modifier & !LOCK_MASK; + if actual_mod != expected_mod { + return false; + } + + if parsed.codepoint == expected_codepoint { + return true; + } + + if parsed.base_layout_key == Some(expected_codepoint) { + return true; + } + + false +} + +fn parse_kitty_sequence(data: &str) -> Option { + parse_csi_u(data) + .or_else(|| parse_arrow_sequence(data)) + .or_else(|| parse_functional_sequence(data)) + .or_else(|| parse_home_end_sequence(data)) +} + +fn parse_csi_u(data: &str) -> Option { + let bytes = data.as_bytes(); + if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'u' { + return None; + } + + let end = bytes.len() - 1; + let mut idx = 2; + let (codepoint, next_idx) = parse_digits(bytes, idx, end)?; + let codepoint = to_i32(codepoint)?; + idx = next_idx; + + let mut base_layout_key = None; + if idx < end && bytes[idx] == b':' { + idx += 1; + let (_, next_idx) = parse_optional_digits(bytes, idx, end); + idx = next_idx; + if idx < end && bytes[idx] == b':' { + idx += 1; + let (base_value, next_idx) = parse_digits(bytes, idx, end)?; + base_layout_key = Some(to_i32(base_value)?); + idx = next_idx; + } + } + + let mod_value = if idx < end && bytes[idx] == b';' { + idx += 1; + let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + mod_value + } else { + 1 + }; + + if idx < end && bytes[idx] == b':' { + idx += 1; + let (_, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + } + + if idx != end || mod_value == 0 { + return None; + } + + Some(ParsedKittySequence { codepoint, base_layout_key, modifier: mod_value - 1 }) +} + +fn parse_arrow_sequence(data: &str) -> Option { + let bytes = data.as_bytes(); + if !bytes.starts_with(b"\x1b[1;") { + return None; + } + + let end = bytes.len(); + let mut idx = 4; + let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + + if idx < end && bytes[idx] == b':' { + idx += 1; + let (_, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + } + + if idx + 1 != end || mod_value == 0 { + return None; + } + + let codepoint = match bytes[idx] { + b'A' => ARROW_UP, + b'B' => ARROW_DOWN, + b'C' => ARROW_RIGHT, + b'D' => ARROW_LEFT, + _ => return None, + }; + + Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) +} + +fn parse_functional_sequence(data: &str) -> Option { + let bytes = data.as_bytes(); + if bytes.len() < 4 || !bytes.starts_with(b"\x1b[") || *bytes.last()? != b'~' { + return None; + } + + let end = bytes.len() - 1; + let mut idx = 2; + let (key_num, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + + let mod_value = if idx < end && bytes[idx] == b';' { + idx += 1; + let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + mod_value + } else { + 1 + }; + + if idx < end && bytes[idx] == b':' { + idx += 1; + let (_, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + } + + if idx != end || mod_value == 0 { + return None; + } + + let codepoint = match key_num { + 2 => FUNC_INSERT, + 3 => FUNC_DELETE, + 5 => FUNC_PAGE_UP, + 6 => FUNC_PAGE_DOWN, + 7 => FUNC_HOME, + 8 => FUNC_END, + _ => return None, + }; + + Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) +} + +fn parse_home_end_sequence(data: &str) -> Option { + let bytes = data.as_bytes(); + if !bytes.starts_with(b"\x1b[1;") { + return None; + } + + let end = bytes.len(); + let mut idx = 4; + let (mod_value, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + + if idx < end && bytes[idx] == b':' { + idx += 1; + let (_, next_idx) = parse_digits(bytes, idx, end)?; + idx = next_idx; + } + + if idx + 1 != end || mod_value == 0 { + return None; + } + + let codepoint = match bytes[idx] { + b'H' => FUNC_HOME, + b'F' => FUNC_END, + _ => return None, + }; + + Some(ParsedKittySequence { codepoint, base_layout_key: None, modifier: mod_value - 1 }) +} + +fn parse_digits(bytes: &[u8], mut idx: usize, end: usize) -> Option<(u32, usize)> { + if idx >= end || !bytes[idx].is_ascii_digit() { + return None; + } + + let mut value: u32 = 0; + while idx < end && bytes[idx].is_ascii_digit() { + value = value + .checked_mul(10)? + .checked_add(u32::from(bytes[idx] - b'0'))?; + idx += 1; + } + + Some((value, idx)) +} + +fn parse_optional_digits(bytes: &[u8], idx: usize, end: usize) -> (Option, usize) { + if idx >= end || !bytes[idx].is_ascii_digit() { + return (None, idx); + } + + let Some((value, next_idx)) = parse_digits(bytes, idx, end) else { + return (None, idx); + }; + (Some(value), next_idx) +} + +fn to_i32(value: u32) -> Option { + i32::try_from(value).ok() +} diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 9e34c7d06..3e6b83fc1 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -25,4 +25,5 @@ pub mod grep; pub mod highlight; pub mod html; pub mod image; +pub mod keys; pub mod text; diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index f137694c4..1ef45c692 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -1,8 +1,8 @@ //! ANSI-aware text measurement and slicing utilities. +use bstr::ByteSlice; use napi::{JsString, JsStringUtf8, bindgen_prelude::*}; use napi_derive::napi; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; const TAB_WIDTH: usize = 3; @@ -208,8 +208,8 @@ impl AnsiCodeTracker { } } -fn extract_ansi_code(text: &str, pos: usize) -> Option { - let bytes = text.as_bytes(); +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; } @@ -245,10 +245,10 @@ fn extract_ansi_code(text: &str, pos: usize) -> Option { } } -fn next_ansi_start(text: &str, mut pos: usize) -> Option { - let bytes = text.as_bytes(); +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(text, pos).is_some() { + if bytes[pos] == 0x1b && extract_ansi_code(bytes, pos).is_some() { return Some(pos); } pos += 1; @@ -267,13 +267,13 @@ fn clamp_u32(value: usize) -> u32 { value.min(u32::MAX as usize) as u32 } -enum TextInput { - Utf8(JsStringUtf8), - Bytes(Uint8Array), +enum TextInput<'a> { + Utf8(JsStringUtf8<'a>), + Bytes(&'a Uint8Array), } -impl TextInput { - fn new(value: Either) -> Result { +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)), @@ -289,16 +289,21 @@ impl TextInput { } } -fn visible_width_impl(text: &str) -> usize { +impl AsRef<[u8]> for TextInput<'_> { + fn as_ref(&self) -> &[u8] { + match self { + Self::Utf8(text) => text.as_slice(), + Self::Bytes(bytes) => bytes.as_ref(), + } + } +} + +fn visible_width_impl(text: impl AsRef<[u8]>) -> usize { + let text = text.as_ref(); if text.is_empty() { return 0; } - let is_pure_ascii = text.bytes().all(|byte| (0x20..=0x7e).contains(&byte)); - if is_pure_ascii { - return text.len(); - } - // Single-pass: skip ANSI codes, measure graphemes let mut i = 0; let mut width = 0; @@ -310,7 +315,7 @@ fn visible_width_impl(text: &str) -> usize { // 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].graphemes(true) { + for (_, _, grapheme) in text[i..next_ansi].grapheme_indices() { width += grapheme_width(grapheme); } i = next_ansi; @@ -319,13 +324,6 @@ fn visible_width_impl(text: &str) -> usize { width } -/// Compute the visible width of a string, ignoring ANSI codes. -#[napi(js_name = "visibleWidth")] -pub fn visible_width(text: Either) -> Result { - let text = TextInput::new(text)?; - Ok(clamp_u32(visible_width_impl(text.as_str()?))) -} - /// Truncate text to a visible width, preserving ANSI codes. #[napi(js_name = "truncateToWidth")] pub fn truncate_to_width( @@ -334,9 +332,9 @@ pub fn truncate_to_width( ellipsis: Either, pad: bool, ) -> Result { - let text = TextInput::new(text)?; + let text = TextInput::new(&text)?; let text = text.as_str()?; - let ellipsis = TextInput::new(ellipsis)?; + let ellipsis = TextInput::new(&ellipsis)?; let ellipsis = ellipsis.as_str()?; let max_width = max_width as usize; let text_visible_width = visible_width_impl(text); @@ -350,7 +348,12 @@ pub fn truncate_to_width( let ellipsis_width = visible_width_impl(ellipsis); let target_width = max_width.saturating_sub(ellipsis_width); if target_width == 0 { - return Ok(ellipsis.graphemes(true).take(max_width).collect()); + return Ok(ellipsis + .as_bytes() + .grapheme_indices() + .take(max_width) + .map(|(_, _, g)| g) + .collect()); } // Streaming: walk string once, copy ANSI codes through, copy graphemes until @@ -369,7 +372,7 @@ pub fn truncate_to_width( // 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[i..next_ansi].graphemes(true) { + 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 @@ -398,11 +401,13 @@ pub fn truncate_to_width( Ok(out) } -fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bool) -> SliceResult { - if length == 0 { - return SliceResult { text: String::new(), width: 0 }; - } - +fn slice_with_width_impl( + line: impl AsRef<[u8]>, + start_col: usize, + length: usize, + strict: bool, +) -> SliceResult { + let line = line.as_ref(); let end_col = start_col + length; let mut result = String::new(); let mut result_width = 0; @@ -413,6 +418,9 @@ fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bo 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 { @@ -424,7 +432,7 @@ fn slice_with_width_impl(line: &str, start_col: usize, length: usize, strict: bo let next_ansi = next_ansi_start(line, i); let end = next_ansi.unwrap_or(line.len()); - for grapheme in line[i..end].graphemes(true) { + 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; @@ -460,12 +468,12 @@ pub fn slice_with_width( length: u32, strict: bool, ) -> Result { - let line = TextInput::new(line)?; - Ok(slice_with_width_impl(line.as_str()?, start_col as usize, length as usize, strict)) + let line = TextInput::new(&line)?; + Ok(slice_with_width_impl(line, start_col as usize, length as usize, strict)) } fn extract_segments_impl( - line: &str, + line: impl AsRef<[u8]>, before_end: usize, after_start: usize, after_len: usize, @@ -483,10 +491,12 @@ fn extract_segments_impl( let mut tracker = AnsiCodeTracker::new(); tracker.clear(); - + let line = line.as_ref(); 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); @@ -499,7 +509,7 @@ fn extract_segments_impl( let next_ansi = next_ansi_start(line, i); let end = next_ansi.unwrap_or(line.len()); - for grapheme in line[i..end].graphemes(true) { + for (_, _, grapheme) in line[i..end].grapheme_indices() { let width = grapheme_width(grapheme); if current_col < before_end { @@ -559,9 +569,9 @@ pub fn extract_segments( after_len: u32, strict_after: bool, ) -> Result { - let line = TextInput::new(line)?; + let line = TextInput::new(&line)?; Ok(extract_segments_impl( - line.as_str()?, + line, before_end as usize, after_start as usize, after_len as usize, diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 3d4a5f136..e43ea1c12 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added + +- Added `matchesKittySequence` function to match Kitty protocol sequences for codepoint and modifier + +### Removed + +- Removed `visibleWidth` function from text utilities ## [9.6.0] - 2026-02-01 ### Added diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index bcfc56b9c..be0cf1bd4 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -75,7 +75,6 @@ export { type SliceWithWidthResult, sliceWithWidth, truncateToWidth, - visibleWidth, } from "./text/index"; // ============================================================================= @@ -89,6 +88,12 @@ export { supportsLanguage, } from "./highlight/index"; +// ============================================================================= +// Keyboard sequence helpers +// ============================================================================= + +export { matchesKittySequence } from "./keys/index"; + // ============================================================================= // HTML to Markdown // ============================================================================= diff --git a/packages/natives/src/keys/index.ts b/packages/natives/src/keys/index.ts new file mode 100644 index 000000000..41db43814 --- /dev/null +++ b/packages/natives/src/keys/index.ts @@ -0,0 +1,10 @@ +/** + * Keyboard sequence utilities powered by native bindings. + */ + +import { native } from "../native"; + +/** Match Kitty protocol sequences for codepoint and modifier. */ +export function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { + return native.matchesKittySequence(data, expectedCodepoint, expectedModifier); +} diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index c034ad4f7..27da49915 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -55,7 +55,6 @@ export interface NativeBindings { getSupportedLanguages(): string[]; SamplingFilter: NativeSamplingFilter; PhotonImage: NativePhotonImageConstructor; - visibleWidth(text: TextInput): number; truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput, pad: boolean): string; sliceWithWidth(line: TextInput, startCol: number, length: number, strict: boolean): SliceWithWidthResult; extractSegments( @@ -65,6 +64,7 @@ export interface NativeBindings { afterLen: number, strictAfter: boolean, ): ExtractSegmentsResult; + matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean; } const require = createRequire(import.meta.url); @@ -133,10 +133,10 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("highlightCode"); checkFn("supportsLanguage"); checkFn("getSupportedLanguages"); - checkFn("visibleWidth"); checkFn("truncateToWidth"); checkFn("sliceWithWidth"); checkFn("extractSegments"); + checkFn("matchesKittySequence"); if (!bindings.PhotonImage?.newFromByteslice) { missing.push("PhotonImage.newFromByteslice"); diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts index 404b12651..71641a4aa 100644 --- a/packages/natives/src/text/index.ts +++ b/packages/natives/src/text/index.ts @@ -18,11 +18,6 @@ export interface ExtractSegmentsResult { export type TextInput = string | Uint8Array; -/** Compute the visible width of a string, ignoring ANSI codes. */ -export function visibleWidth(text: TextInput): number { - return native.visibleWidth(text); -} - /** * Truncate a string to a visible width, preserving ANSI codes. */ diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b07b81348..2618c5797 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Changed + +- Improved performance of key ID parsing with optimized cache lookup strategy +- Simplified `visibleWidth` calculation to use consistent Bun.stringWidth approach for all string lengths + +### Removed + +- Removed `visibleWidth` benchmark file in favor of Kitty sequence benchmarking ## [9.5.0] - 2026-02-01 ### Changed diff --git a/packages/tui/bench/kitty-sequence.ts b/packages/tui/bench/kitty-sequence.ts new file mode 100644 index 000000000..a19833f51 --- /dev/null +++ b/packages/tui/bench/kitty-sequence.ts @@ -0,0 +1,50 @@ +import { matchesKittySequence as nativeMatchesKittySequence } from "@oh-my-pi/pi-natives"; +import { parseKittySequence } from "../src/keys"; + +const ITERATIONS = 2000; +const LOCK_MASK = 64 + 128; + +const samples = [ + { name: "ctrl+a", data: "\x1b[97;5u", codepoint: 97, modifier: 4 }, + { name: "shift+tab", data: "\x1b[9;2u", codepoint: 9, modifier: 1 }, + { name: "alt+enter", data: "\x1b[13;3u", codepoint: 13, modifier: 2 }, + { name: "ctrl+right", data: "\x1b[1;5C", codepoint: -3, modifier: 4 }, + { name: "shift+delete", data: "\x1b[3;2~", codepoint: -10, modifier: 1 }, + { name: "base-layout", data: "\x1b[108::97;5u", codepoint: 97, modifier: 4 }, +]; + +function matchesKittySequenceJs(data: string, expectedCodepoint: number, expectedModifier: number): boolean { + const parsed = parseKittySequence(data); + if (!parsed) return false; + const actualMod = parsed.modifier & ~LOCK_MASK; + const expectedMod = expectedModifier & ~LOCK_MASK; + if (actualMod !== expectedMod) return false; + if (parsed.codepoint === expectedCodepoint) return true; + if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true; + return false; +} + +function bench(name: string, fn: () => void): number { + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + fn(); + } + const elapsed = performance.now() - start; + const perOp = (elapsed / ITERATIONS).toFixed(4); + console.log(`${name}: ${elapsed.toFixed(2)}ms total (${perOp}ms/op)`); + return elapsed; +} + +console.log(`Kitty sequence match benchmark (${ITERATIONS} iterations)\n`); + +bench("js/parse+match", () => { + for (const sample of samples) { + matchesKittySequenceJs(sample.data, sample.codepoint, sample.modifier); + } +}); + +bench("native/match", () => { + for (const sample of samples) { + nativeMatchesKittySequence(sample.data, sample.codepoint, sample.modifier); + } +}); diff --git a/packages/tui/bench/visible-width.ts b/packages/tui/bench/visible-width.ts deleted file mode 100644 index 4b1d72328..000000000 --- a/packages/tui/bench/visible-width.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * 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 { visibleWidth 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/keys.ts b/packages/tui/src/keys.ts index 2905c8c95..3a520eec9 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -18,6 +18,8 @@ * - isKittyProtocolActive() - Query global Kitty protocol state */ +import { matchesKittySequence } from "@oh-my-pi/pi-natives"; + // ============================================================================= // Global Kitty Protocol State // ============================================================================= @@ -620,26 +622,6 @@ export function parseKittySequence(data: string): ParsedKittySequence | null { return null; } -function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { - const parsed = parseKittySequence(data); - if (!parsed) return false; - const actualMod = parsed.modifier & ~LOCK_MASK; - const expectedMod = expectedModifier & ~LOCK_MASK; - - // Check if modifiers match - if (actualMod !== expectedMod) return false; - - // Primary match: codepoint matches directly - if (parsed.codepoint === expectedCodepoint) return true; - - // Alternate match: use base layout key for non-Latin keyboard layouts - // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports - // the base layout key (the key in standard PC-101 layout) - if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) return true; - - return false; -} - /** * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ * This is used by terminals when Kitty protocol is not enabled. @@ -666,23 +648,26 @@ function rawCtrlChar(letter: string): string { type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean }; -const PARSED_KEY_ID_CACHE = new Map(); +const PARSED_KEY_ID_CACHE = new Map(); -function parseKeyId(keyId: string): ParsedKeyId | null { +function parseKeyIdSlow(keyId: string): ParsedKeyId | null { const normalizedKeyId = keyId.toLowerCase(); - const cached = PARSED_KEY_ID_CACHE.get(normalizedKeyId); - if (cached) return cached; - const parts = normalizedKeyId.split("+"); const key = parts[parts.length - 1]; if (!key) return null; - const parsed = { + return { key, ctrl: parts.includes("ctrl"), shift: parts.includes("shift"), alt: parts.includes("alt"), }; - PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed); +} + +function parseKeyId(keyId: string): ParsedKeyId | null { + const cached = PARSED_KEY_ID_CACHE.get(keyId); + if (cached !== undefined) return cached; + const parsed = parseKeyIdSlow(keyId); + PARSED_KEY_ID_CACHE.set(keyId, parsed); return parsed; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 28f5408d9..5ca551cc9 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -2,7 +2,6 @@ import { extractSegments as nativeExtractSegments, sliceWithWidth as nativeSliceWithWidth, truncateToWidth as nativeTruncateToWidth, - visibleWidth as nativeVisibleWidth, } from "@oh-my-pi/pi-natives"; // Pre-allocated space buffer for padding @@ -30,7 +29,6 @@ export function getSegmenter(): Intl.Segmenter { // Cache for non-ASCII strings const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); -const NATIVE_WIDTH_THRESHOLD = 256; /** * Calculate the visible width of a string in terminal columns. @@ -42,15 +40,17 @@ export function visibleWidth(str: string): number { // Fast path: pure ASCII printable let isPureAscii = true; + let tabLength = 0; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); - if (code < 0x20 || code > 0x7e) { + if (code === 9) { + tabLength += 3; + } else if (code < 0x20 || code > 0x7e) { isPureAscii = false; - break; } } if (isPureAscii) { - return str.length; + return str.length + tabLength; } // Check cache @@ -59,25 +59,8 @@ export function visibleWidth(str: string): number { return cached; } - let width: number; - if (str.length <= NATIVE_WIDTH_THRESHOLD) { - // Normalize: tabs to 3 spaces, strip ANSI escape codes - let clean = str; - if (str.includes("\t")) { - clean = clean.replace(/\t/g, " "); - } - if (clean.includes("\x1b")) { - // Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J) - clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, ""); - // Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07 - clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); - } - width = Bun.stringWidth(clean); - } else { - width = nativeVisibleWidth(str); - } - // Cache result + const width = Bun.stringWidth(str) + tabLength; if (widthCache.size >= WIDTH_CACHE_SIZE) { const firstKey = widthCache.keys().next().value; if (firstKey !== undefined) {