From 8ef11355e792977d5e46396414bdb6f793a337c9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 29 Jan 2026 10:45:32 +0100 Subject: [PATCH] perf(tui,natives): added native text utilities with JS fast path for short strings - Added native text utilities in pi-natives (visible width, truncation, slicing, segment extraction) - Wired pi-tui to use native implementations with short-string JS fast path - Optimized key matching via cached parsed key ids - Refactored visual truncation to reuse Text instances - Reduced theme color lookups by switching from Map to plain records - Regenerated WASM artifacts Benchmarks (after vs before, lower is better): - truncateToWidth/ansi: 20.37ms vs 38.13ms (2000 iters) - sliceWithWidth/ansi: 5.12ms vs 20.39ms - extractSegments/ansi: 8.92ms vs 27.60ms - truncateToVisualLines: 3.51ms vs 411.55ms (500 iters) - visibleWidth/ansi: 0.24ms vs 0.31ms --- Cargo.lock | 14 + crates/pi-natives/Cargo.toml | 2 + crates/pi-natives/src/lib.rs | 1 + crates/pi-natives/src/text.rs | 521 ++++++++++++++++++ packages/coding-agent/bench/rendering.ts | 41 ++ .../src/modes/components/visual-truncate.ts | 16 +- .../coding-agent/src/modes/theme/theme.ts | 24 +- .../coding-agent/test/visual-truncate.test.ts | 27 + packages/natives/src/index.ts | 13 + packages/natives/src/text/index.ts | 64 +++ packages/natives/wasm/pi_natives.d.ts | 23 +- packages/natives/wasm/pi_natives.js | 79 ++- packages/natives/wasm/pi_natives_bg.wasm | 4 +- packages/natives/wasm/pi_natives_bg.wasm.d.ts | 4 + packages/tui/bench/text-layout.ts | 57 ++ packages/tui/package.json | 1 + packages/tui/src/keys.ts | 16 +- packages/tui/src/utils.ts | 419 +------------- packages/tui/test/keys.test.ts | 20 + packages/tui/test/text-utils.test.ts | 36 ++ 20 files changed, 968 insertions(+), 414 deletions(-) create mode 100644 crates/pi-natives/src/text.rs create mode 100644 packages/coding-agent/bench/rendering.ts create mode 100644 packages/coding-agent/test/visual-truncate.test.ts create mode 100644 packages/natives/src/text/index.ts create mode 100644 packages/tui/bench/text-layout.ts create mode 100644 packages/tui/test/keys.test.ts create mode 100644 packages/tui/test/text-utils.test.ts diff --git a/Cargo.lock b/Cargo.lock index ecd37253b..85ec69efd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,8 @@ dependencies = [ "image", "serde", "serde-wasm-bindgen", + "unicode-segmentation", + "unicode-width", "wasm-bindgen", ] @@ -385,6 +387,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "wasm-bindgen" version = "0.2.108" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 1c2b7a245..57730a962 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -20,6 +20,8 @@ grep-regex = "0.1.14" grep-searcher = "0.1.16" grep-matcher = "0.1.8" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +unicode-segmentation = "1.11" +unicode-width = "0.2" # Better panic messages in debug builds console_error_panic_hook = { version = "0.1.7", optional = true } diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index c5aaa5ecc..f7f5f2a5b 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -5,6 +5,7 @@ //! content here for matching. pub mod image; +pub mod text; use std::io::{self, Cursor}; diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs new file mode 100644 index 000000000..ef77b4b59 --- /dev/null +++ b/crates/pi-natives/src/text.rs @@ -0,0 +1,521 @@ +//! ANSI-aware text measurement and slicing utilities. + +use serde::Serialize; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; +use wasm_bindgen::prelude::*; + +const TAB_WIDTH: usize = 3; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SliceResult { + text: String, + width: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExtractSegmentsResult { + before: String, + before_width: usize, + after: String, + after_width: usize, +} + +struct AnsiCodeTracker { + bold: bool, + dim: bool, + italic: bool, + underline: bool, + blink: bool, + inverse: bool, + hidden: bool, + strikethrough: bool, + fg_color: Option, + bg_color: Option, +} + +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 code = match parts[i].parse::() { + Ok(value) => value, + Err(_) => { + 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(";")) + } +} + +fn extract_ansi_code(text: &str, pos: usize) -> Option { + let bytes = text.as_bytes(); + if pos >= bytes.len() || bytes[pos] != 0x1b { + return None; + } + if pos + 1 >= bytes.len() { + return None; + } + + match bytes[pos + 1] { + b'[' => { + 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, + } + } + None + }, + b']' => { + let mut j = pos + 2; + while j < bytes.len() { + if bytes[j] == 0x07 { + return Some(j + 1 - pos); + } + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + return Some(j + 2 - pos); + } + j += 1; + } + None + }, + _ => None, + } +} + +fn next_ansi_start(text: &str, mut pos: usize) -> Option { + let bytes = text.as_bytes(); + while pos < bytes.len() { + if bytes[pos] == 0x1b && extract_ansi_code(text, pos).is_some() { + return Some(pos); + } + pos += 1; + } + None +} + +fn grapheme_width(grapheme: &str) -> usize { + if grapheme == "\t" { + return TAB_WIDTH; + } + UnicodeWidthStr::width(grapheme) +} + +/// Compute the visible width of a string, ignoring ANSI codes. +#[wasm_bindgen] +pub fn visible_width(text: &str) -> usize { + if text.is_empty() { + return 0; + } + + let is_pure_ascii = text + .bytes() + .all(|byte| byte >= 0x20 && byte <= 0x7e); + if is_pure_ascii { + return text.len(); + } + + let mut width = 0; + let mut i = 0; + while i < text.len() { + if let Some(len) = extract_ansi_code(text, i) { + i += len; + continue; + } + + let next_ansi = next_ansi_start(text, i); + let end = next_ansi.unwrap_or(text.len()); + for grapheme in text[i..end].graphemes(true) { + width += grapheme_width(grapheme); + } + i = end; + } + + width +} + +/// Truncate text to a visible width, preserving ANSI codes. +#[wasm_bindgen] +pub fn truncate_to_width(text: &str, max_width: usize, ellipsis: &str, pad: bool) -> String { + let text_visible_width = visible_width(text); + if text_visible_width <= max_width { + if pad { + return format!("{}{}", text, " ".repeat(max_width - text_visible_width)); + } + return text.to_string(); + } + + let ellipsis_width = visible_width(ellipsis); + let target_width = max_width.saturating_sub(ellipsis_width); + if target_width == 0 { + return ellipsis.graphemes(true).take(max_width).collect(); + } + + let mut segments: Vec<(bool, &str)> = Vec::new(); + let mut i = 0; + while i < text.len() { + if let Some(len) = extract_ansi_code(text, i) { + segments.push((true, &text[i..i + len])); + i += len; + continue; + } + + let next_ansi = next_ansi_start(text, i); + let end = next_ansi.unwrap_or(text.len()); + for grapheme in text[i..end].graphemes(true) { + segments.push((false, grapheme)); + } + i = end; + } + + let mut result = String::new(); + let mut current_width = 0; + for (is_ansi, value) in segments { + if is_ansi { + result.push_str(value); + continue; + } + + if value.is_empty() { + continue; + } + + let width = grapheme_width(value); + if current_width + width > target_width { + break; + } + result.push_str(value); + current_width += width; + } + + let mut truncated = format!("{}\x1b[0m{}", result, ellipsis); + if pad { + let truncated_width = visible_width(&truncated); + if truncated_width < max_width { + truncated.push_str(&" ".repeat(max_width - truncated_width)); + } + } + + truncated +} + +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, + }; + } + + 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(); + + while i < line.len() { + if let Some(len) = extract_ansi_code(line, i) { + let code = &line[i..i + len]; + 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].graphemes(true) { + 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(); + } + result.push_str(grapheme); + result_width += width; + } + + current_col += width; + if current_col >= end_col { + break; + } + } + i = end; + if current_col >= end_col { + break; + } + } + + SliceResult { + text: result, + width: result_width, + } +} + +/// Slice a range of visible columns from a line. +#[wasm_bindgen] +pub fn slice_with_width(line: &str, start_col: usize, length: usize, strict: bool) -> JsValue { + let result = slice_with_width_impl(line, start_col, length, strict); + serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL) +} + +fn extract_segments_impl( + line: &str, + 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; + let after_end = after_start + after_len; + + let mut tracker = AnsiCodeTracker::new(); + tracker.clear(); + + while i < line.len() { + if let Some(len) = extract_ansi_code(line, i) { + let code = &line[i..i + len]; + 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); + } + 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].graphemes(true) { + let width = grapheme_width(grapheme); + + 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; + } + after.push_str(grapheme); + after_width += width; + } + } + + current_col += width; + let done = if after_len == 0 { + current_col >= before_end + } else { + current_col >= after_end + }; + if done { + break; + } + } + i = end; + let done = if after_len == 0 { + current_col >= before_end + } else { + current_col >= after_end + }; + if done { + break; + } + } + + ExtractSegmentsResult { + before, + before_width, + after, + after_width, + } +} + +/// Extract the before/after slices around an overlay region. +#[wasm_bindgen] +pub fn extract_segments( + line: &str, + before_end: usize, + after_start: usize, + after_len: usize, + strict_after: bool, +) -> JsValue { + let result = extract_segments_impl(line, before_end, after_start, after_len, strict_after); + serde_wasm_bindgen::to_value(&result).unwrap_or(JsValue::NULL) +} diff --git a/packages/coding-agent/bench/rendering.ts b/packages/coding-agent/bench/rendering.ts new file mode 100644 index 000000000..99382499e --- /dev/null +++ b/packages/coding-agent/bench/rendering.ts @@ -0,0 +1,41 @@ +import { initTheme } from "../src/modes/theme/theme"; +import { truncateToVisualLines } from "../src/modes/components/visual-truncate"; +import { WelcomeComponent } from "../src/modes/components/welcome"; + +const ITERATIONS = 500; +const WIDTH = 100; + +const longText = Array.from({ length: 200 }) + .map((_, i) => `Line ${i + 1}: \x1b[32mcolored content\x1b[0m with emojis 🚀✨ and extra padding`) + .join("\n"); + +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; +} + +await initTheme("dark"); + +console.log(`Rendering benchmark (${ITERATIONS} iterations)\n`); + +bench("truncateToVisualLines", () => { + truncateToVisualLines(longText, 20, WIDTH, 1); +}); + +const welcome = new WelcomeComponent("8.12.3", "claude-3.7", "anthropic", [ + { name: "Test session", timeAgo: "2m" }, + { name: "Another session", timeAgo: "1h" }, +], [ + { name: "tsserver", status: "ready", fileTypes: ["ts", "tsx", "js"] }, + { name: "rust-analyzer", status: "connecting", fileTypes: ["rs"] }, +]); + +bench("WelcomeComponent.render", () => { + welcome.render(WIDTH); +}); diff --git a/packages/coding-agent/src/modes/components/visual-truncate.ts b/packages/coding-agent/src/modes/components/visual-truncate.ts index d18444927..9c95b748c 100644 --- a/packages/coding-agent/src/modes/components/visual-truncate.ts +++ b/packages/coding-agent/src/modes/components/visual-truncate.ts @@ -11,6 +11,17 @@ export interface VisualTruncateResult { skippedCount: number; } +const textCache = new Map(); + +function getCachedText(paddingX: number): Text { + let text = textCache.get(paddingX); + if (!text) { + text = new Text("", paddingX, 0); + textCache.set(paddingX, text); + } + return text; +} + /** * Truncate text to a maximum number of visual lines (from the end). * This accounts for line wrapping based on terminal width. @@ -34,7 +45,10 @@ export function truncateToVisualLines( } // Create a temporary Text component to render and get visual lines - const tempText = new Text(text, paddingX, 0); + const tempText = getCachedText(paddingX); + if (tempText.getText() !== text) { + tempText.setText(text); + } const allVisualLines = tempText.render(width); if (allVisualLines.length <= maxVisualLines) { diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 00de95c30..8d3e5a8eb 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1180,8 +1180,8 @@ const langMap: Record = { }; export class Theme { - private fgColors: Map; - private bgColors: Map; + private fgColors: Record; + private bgColors: Record; private mode: ColorMode; private symbols: SymbolMap; private symbolPreset: SymbolPreset; @@ -1190,18 +1190,18 @@ export class Theme { fgColors: Record, bgColors: Record, mode: ColorMode, - symbolPreset: SymbolPreset = "unicode", - symbolOverrides: Record = {}, + symbolPreset: SymbolPreset, + symbolOverrides: Partial>, ) { this.mode = mode; this.symbolPreset = symbolPreset; - this.fgColors = new Map(); + this.fgColors = {} as Record; for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { - this.fgColors.set(key, fgAnsi(value, mode)); + this.fgColors[key] = fgAnsi(value, mode); } - this.bgColors = new Map(); + this.bgColors = {} as Record; for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { - this.bgColors.set(key, bgAnsi(value, mode)); + this.bgColors[key] = bgAnsi(value, mode); } // Build symbol map from preset + overrides const baseSymbols = SYMBOL_PRESETS[symbolPreset]; @@ -1216,13 +1216,13 @@ export class Theme { } fg(color: ThemeColor, text: string): string { - const ansi = this.fgColors.get(color); + const ansi = this.fgColors[color]; if (!ansi) throw new Error(`Unknown theme color: ${color}`); return `${ansi}${text}\x1b[39m`; // Reset only foreground color } bg(color: ThemeBg, text: string): string { - const ansi = this.bgColors.get(color); + const ansi = this.bgColors[color]; if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return `${ansi}${text}\x1b[49m`; // Reset only background color } @@ -1248,13 +1248,13 @@ export class Theme { } getFgAnsi(color: ThemeColor): string { - const ansi = this.fgColors.get(color); + const ansi = this.fgColors[color]; if (!ansi) throw new Error(`Unknown theme color: ${color}`); return ansi; } getBgAnsi(color: ThemeBg): string { - const ansi = this.bgColors.get(color); + const ansi = this.bgColors[color]; if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return ansi; } diff --git a/packages/coding-agent/test/visual-truncate.test.ts b/packages/coding-agent/test/visual-truncate.test.ts new file mode 100644 index 000000000..aacacbc5d --- /dev/null +++ b/packages/coding-agent/test/visual-truncate.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { truncateToVisualLines } from "@oh-my-pi/pi-coding-agent/modes/components/visual-truncate"; + +describe("truncateToVisualLines", () => { + it("returns empty output for empty text", () => { + const result = truncateToVisualLines("", 3, 10); + + expect(result.visualLines).toEqual([]); + expect(result.skippedCount).toBe(0); + }); + + it("truncates to the last visual lines after wrapping", () => { + const text = "one two three four"; + const result = truncateToVisualLines(text, 1, 10, 0); + + expect(result.visualLines).toEqual(["three four"]); + expect(result.skippedCount).toBe(1); + }); + + it("applies horizontal padding to rendered lines", () => { + const text = "one"; + const result = truncateToVisualLines(text, 1, 5, 1); + + expect(result.visualLines).toEqual([" one "]); + expect(result.skippedCount).toBe(0); + }); +}); diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index 357b3712d..07b3c9701 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -133,6 +133,19 @@ export { terminate as terminateImageWorker, } from "./image/index"; +// ============================================================================= +// Text utilities +// ============================================================================= + +export { + type ExtractSegmentsResult, + extractSegments, + type SliceWithWidthResult, + sliceWithWidth, + truncateToWidth, + visibleWidth, +} from "./text/index"; + // ============================================================================= // Worker Pool (shared infrastructure) // ============================================================================= diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts new file mode 100644 index 000000000..654d516fc --- /dev/null +++ b/packages/natives/src/text/index.ts @@ -0,0 +1,64 @@ +/** + * ANSI-aware text utilities powered by WASM. + */ + +import * as wasm from "../../wasm/pi_natives"; + +export interface SliceWithWidthResult { + text: string; + width: number; +} + +export interface ExtractSegmentsResult { + before: string; + beforeWidth: number; + after: string; + afterWidth: number; +} + +type WasmTextExports = typeof wasm & { + visible_width: (text: string) => number; + truncate_to_width: (text: string, maxWidth: number, ellipsis: string, pad: boolean) => string; + slice_with_width: (line: string, startCol: number, length: number, strict: boolean) => SliceWithWidthResult; + extract_segments: ( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter: boolean, + ) => ExtractSegmentsResult; +}; + +const wasmText = wasm as WasmTextExports; + +/** Compute the visible width of a string, ignoring ANSI codes. */ +export function visibleWidth(text: string): number { + return wasmText.visible_width(text); +} + +/** + * Truncate a string to a visible width, preserving ANSI codes. + */ +export function truncateToWidth(text: string, maxWidth: number, ellipsis = "…", pad = false): string { + return wasmText.truncate_to_width(text, maxWidth, ellipsis, pad); +} + +/** + * Slice a range of visible columns from a line. + */ +export function sliceWithWidth(line: string, startCol: number, length: number, strict = false): SliceWithWidthResult { + return wasmText.slice_with_width(line, startCol, length, strict); +} + +/** + * Extract before/after segments around an overlay region. + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter = false, +): ExtractSegmentsResult { + return wasmText.extract_segments(line, beforeEnd, afterStart, afterLen, strictAfter); +} diff --git a/packages/natives/wasm/pi_natives.d.ts b/packages/natives/wasm/pi_natives.d.ts index d4cd84f6b..0f1c6f5f8 100644 --- a/packages/natives/wasm/pi_natives.d.ts +++ b/packages/natives/wasm/pi_natives.d.ts @@ -55,7 +55,8 @@ export class PhotonImage { */ get_width(): number; /** - * Create a new PhotonImage from encoded image bytes (PNG, JPEG, WebP, GIF). + * Create a new `PhotonImage` from encoded image bytes (PNG, JPEG, WebP, + * GIF). */ static new_from_byteslice(bytes: Uint8Array): PhotonImage; } @@ -71,6 +72,11 @@ export enum SamplingFilter { Lanczos3 = 5, } +/** + * Extract the before/after slices around an overlay region. + */ +export function extract_segments(line: string, before_end: number, after_start: number, after_len: number, strict_after: boolean): any; + /** * Quick check if content matches a pattern. */ @@ -86,3 +92,18 @@ export function resize(image: PhotonImage, width: number, height: number, filter * For repeated searches with the same pattern, use [`CompiledPattern`]. */ export function search(content: string, options: any): any; + +/** + * Slice a range of visible columns from a line. + */ +export function slice_with_width(line: string, start_col: number, length: number, strict: boolean): any; + +/** + * Truncate text to a visible width, preserving ANSI codes. + */ +export function truncate_to_width(text: string, max_width: number, ellipsis: string, pad: boolean): string; + +/** + * Compute the visible width of a string, ignoring ANSI codes. + */ +export function visible_width(text: string): number; diff --git a/packages/natives/wasm/pi_natives.js b/packages/natives/wasm/pi_natives.js index 655c871c8..65632409a 100644 --- a/packages/natives/wasm/pi_natives.js +++ b/packages/natives/wasm/pi_natives.js @@ -1,7 +1,7 @@ -/* @ts-self-types="./pi_natives.d.ts" */ - import wasmPath from "./pi_natives_bg.wasm"; +/* @ts-self-types="./pi_natives.d.ts" */ + /** * A compiled regex matcher that can be reused across multiple searches. */ @@ -173,7 +173,8 @@ export class PhotonImage { return ret >>> 0; } /** - * Create a new PhotonImage from encoded image bytes (PNG, JPEG, WebP, GIF). + * Create a new `PhotonImage` from encoded image bytes (PNG, JPEG, WebP, + * GIF). * @param {Uint8Array} bytes * @returns {PhotonImage} */ @@ -209,6 +210,22 @@ export const SamplingFilter = Object.freeze({ Lanczos3: 5, "5": "Lanczos3", }); +/** + * Extract the before/after slices around an overlay region. + * @param {string} line + * @param {number} before_end + * @param {number} after_start + * @param {number} after_len + * @param {boolean} strict_after + * @returns {any} + */ +export function extract_segments(line, before_end, after_start, after_len, strict_after) { + const ptr0 = passStringToWasm0(line, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.extract_segments(ptr0, len0, before_end, after_start, after_len, strict_after); + return takeObject(ret); +} + /** * Quick check if content matches a pattern. * @param {string} content @@ -265,6 +282,62 @@ export function search(content, options) { return takeObject(ret); } +/** + * Slice a range of visible columns from a line. + * @param {string} line + * @param {number} start_col + * @param {number} length + * @param {boolean} strict + * @returns {any} + */ +export function slice_with_width(line, start_col, length, strict) { + const ptr0 = passStringToWasm0(line, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.slice_with_width(ptr0, len0, start_col, length, strict); + return takeObject(ret); +} + +/** + * Truncate text to a visible width, preserving ANSI codes. + * @param {string} text + * @param {number} max_width + * @param {string} ellipsis + * @param {boolean} pad + * @returns {string} + */ +export function truncate_to_width(text, max_width, ellipsis, pad) { + let deferred3_0; + let deferred3_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passStringToWasm0(text, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(ellipsis, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + wasm.truncate_to_width(retptr, ptr0, len0, max_width, ptr1, len1, pad); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred3_0 = r0; + deferred3_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export3(deferred3_0, deferred3_1, 1); + } +} + +/** + * Compute the visible width of a string, ignoring ANSI codes. + * @param {string} text + * @returns {number} + */ +export function visible_width(text) { + const ptr0 = passStringToWasm0(text, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.visible_width(ptr0, len0); + return ret >>> 0; +} + function __wbg_get_imports() { const import0 = { __proto__: null, diff --git a/packages/natives/wasm/pi_natives_bg.wasm b/packages/natives/wasm/pi_natives_bg.wasm index 5386809ca..c5e1ed345 100644 --- a/packages/natives/wasm/pi_natives_bg.wasm +++ b/packages/natives/wasm/pi_natives_bg.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f1112971f9faa2106c20a5428f9d01684e6b35810482ff0878e65dd413fc6b91 -size 1897187 +oid sha256:6d40e6ace58364b5f198e84c9fcb8d28d93e756fc66c67f755324e3984c4b069 +size 1950251 diff --git a/packages/natives/wasm/pi_natives_bg.wasm.d.ts b/packages/natives/wasm/pi_natives_bg.wasm.d.ts index d72c0249e..0f6333b73 100644 --- a/packages/natives/wasm/pi_natives_bg.wasm.d.ts +++ b/packages/natives/wasm/pi_natives_bg.wasm.d.ts @@ -6,6 +6,7 @@ export const __wbg_photonimage_free: (a: number, b: number) => void; export const compiledpattern_has_match: (a: number, b: number, c: number) => number; export const compiledpattern_new: (a: number, b: number) => void; export const compiledpattern_search: (a: number, b: number, c: number, d: number, e: number) => number; +export const extract_segments: (a: number, b: number, c: number, d: number, e: number, f: number) => number; export const has_match: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; export const photonimage_get_bytes: (a: number, b: number) => void; export const photonimage_get_bytes_jpeg: (a: number, b: number, c: number) => void; @@ -14,6 +15,9 @@ export const photonimage_get_width: (a: number) => number; export const photonimage_new_from_byteslice: (a: number, b: number, c: number) => void; export const resize: (a: number, b: number, c: number, d: number) => number; export const search: (a: number, b: number, c: number) => number; +export const slice_with_width: (a: number, b: number, c: number, d: number, e: number) => number; +export const truncate_to_width: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; +export const visible_width: (a: number, b: number) => number; export const compiledpattern_has_match_bytes: (a: number, b: number, c: number) => number; export const compiledpattern_search_bytes: (a: number, b: number, c: number, d: number, e: number) => number; export const __wbindgen_export: (a: number, b: number) => number; diff --git a/packages/tui/bench/text-layout.ts b/packages/tui/bench/text-layout.ts new file mode 100644 index 000000000..4bd2e8467 --- /dev/null +++ b/packages/tui/bench/text-layout.ts @@ -0,0 +1,57 @@ +import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments } from "../src/utils"; +import { matchesKey } from "../src/keys"; + +const ITERATIONS = 2000; + +const samples = { + plain: "hello world this is a plain ASCII string with some words", + ansi: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with emoji 😅😅", + links: "prefix \x1b]8;;https://example.com\x07link\x1b]8;;\x07 suffix", + wide: "日本語のテキストとemoji 🚀✨ mixed with ascii", + wrapped: "This is a long line that should wrap multiple times when rendered with ANSI \x1b[32mcolors\x1b[0m and tabs\tbetween words.", +}; + +const wrapWidth = 40; + +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(`Text layout benchmark (${ITERATIONS} iterations)\n`); + +bench("visibleWidth/plain", () => { + visibleWidth(samples.plain); +}); + +bench("visibleWidth/ansi", () => { + visibleWidth(samples.ansi); +}); + +bench("truncateToWidth/ansi", () => { + truncateToWidth(samples.ansi, 32, "…", true); +}); + +bench("wrapTextWithAnsi/ansi", () => { + wrapTextWithAnsi(samples.wrapped, wrapWidth); +}); + +bench("sliceWithWidth/ansi", () => { + sliceWithWidth(samples.ansi, 3, 18, true); +}); + +bench("extractSegments/ansi", () => { + extractSegments(samples.ansi, 10, 20, 15, true); +}); + +bench("matchesKey", () => { + matchesKey("\x1b[A", "up"); + matchesKey("\x1b[1;5C", "ctrl+right"); + matchesKey("\x1b[1;2D", "shift+left"); +}); diff --git a/packages/tui/package.json b/packages/tui/package.json index 7004233c8..74b7fe96d 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -47,6 +47,7 @@ "bun": ">=1.3.7" }, "dependencies": { + "@oh-my-pi/pi-natives": "workspace:*", "@types/mime-types": "^3.0.1", "chalk": "^5.6.2", "marked": "^17.0.1", diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index ed095258a..2905c8c95 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -664,16 +664,26 @@ function rawCtrlChar(letter: string): string { return String.fromCharCode(code); } -function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { - const parts = keyId.toLowerCase().split("+"); +type ParsedKeyId = { key: string; ctrl: boolean; shift: boolean; alt: boolean }; + +const PARSED_KEY_ID_CACHE = new Map(); + +function parseKeyId(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; - return { + const parsed = { key, ctrl: parts.includes("ctrl"), shift: parts.includes("shift"), alt: parts.includes("alt"), }; + PARSED_KEY_ID_CACHE.set(normalizedKeyId, parsed); + return parsed; } /** diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 755beed52..bbf68a8a1 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,3 +1,10 @@ +import { + extractSegments as nativeExtractSegments, + sliceWithWidth as nativeSliceWithWidth, + truncateToWidth as nativeTruncateToWidth, + visibleWidth as nativeVisibleWidth, +} from "@oh-my-pi/pi-natives"; + // Grapheme segmenter (shared instance) const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); @@ -11,6 +18,7 @@ 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. @@ -39,19 +47,23 @@ export function visibleWidth(str: string): number { return cached; } - // Normalize: tabs to 3 spaces, strip ANSI escape codes - let clean = str; - if (str.includes("\t")) { - clean = clean.replace(/\t/g, " "); + 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); } - 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, ""); - } - - const width = Bun.stringWidth(clean); // Cache result if (widthCache.size >= WIDTH_CACHE_SIZE) { @@ -98,211 +110,6 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt return null; } -/** - * Track active ANSI SGR codes to preserve styling across line breaks. - */ -class AnsiCodeTracker { - // Track individual attributes separately so we can reset them specifically - private bold = false; - private dim = false; - private italic = false; - private underline = false; - private blink = false; - private inverse = false; - private hidden = false; - private strikethrough = false; - private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" - private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" - - process(ansiCode: string): void { - if (!ansiCode.endsWith("m")) { - return; - } - - // Extract the parameters between \x1b[ and m - const match = ansiCode.match(/\x1b\[([\d;]*)m/); - if (!match) return; - - const params = match[1]; - if (params === "" || params === "0") { - // Full reset - this.reset(); - return; - } - - // Parse parameters (can be semicolon-separated) - const parts = params.split(";"); - let i = 0; - while (i < parts.length) { - const code = Number.parseInt(parts[i], 10); - - // Handle 256-color and RGB codes which consume multiple parameters - if (code === 38 || code === 48) { - // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg) - // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg) - if (parts[i + 1] === "5" && parts[i + 2] !== undefined) { - // 256 color: 38;5;N or 48;5;N - const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`; - if (code === 38) { - this.fgColor = colorCode; - } else { - this.bgColor = colorCode; - } - i += 3; - continue; - } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) { - // RGB color: 38;2;R;G;B or 48;2;R;G;B - const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`; - if (code === 38) { - this.fgColor = colorCode; - } else { - this.bgColor = colorCode; - } - i += 5; - continue; - } - } - - // Standard SGR codes - switch (code) { - case 0: - this.reset(); - break; - case 1: - this.bold = true; - break; - case 2: - this.dim = true; - break; - case 3: - this.italic = true; - break; - case 4: - this.underline = true; - break; - case 5: - this.blink = true; - break; - case 7: - this.inverse = true; - break; - case 8: - this.hidden = true; - break; - case 9: - this.strikethrough = true; - break; - case 21: - this.bold = false; - break; // Some terminals - case 22: - this.bold = false; - this.dim = false; - break; - case 23: - this.italic = false; - break; - case 24: - this.underline = false; - break; - case 25: - this.blink = false; - break; - case 27: - this.inverse = false; - break; - case 28: - this.hidden = false; - break; - case 29: - this.strikethrough = false; - break; - case 39: - this.fgColor = null; - break; // Default fg - case 49: - this.bgColor = null; - break; // Default bg - default: - // Standard foreground colors 30-37, 90-97 - if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { - this.fgColor = String(code); - } - // Standard background colors 40-47, 100-107 - else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { - this.bgColor = String(code); - } - break; - } - i++; - } - } - - private reset(): void { - this.bold = false; - this.dim = false; - this.italic = false; - this.underline = false; - this.blink = false; - this.inverse = false; - this.hidden = false; - this.strikethrough = false; - this.fgColor = null; - this.bgColor = null; - } - - /** Clear all state for reuse. */ - clear(): void { - this.reset(); - } - - getActiveCodes(): string { - const codes: string[] = []; - if (this.bold) codes.push("1"); - if (this.dim) codes.push("2"); - if (this.italic) codes.push("3"); - if (this.underline) codes.push("4"); - if (this.blink) codes.push("5"); - if (this.inverse) codes.push("7"); - if (this.hidden) codes.push("8"); - if (this.strikethrough) codes.push("9"); - if (this.fgColor) codes.push(this.fgColor); - if (this.bgColor) codes.push(this.bgColor); - - if (codes.length === 0) return ""; - return `\x1b[${codes.join(";")}m`; - } - - hasActiveCodes(): boolean { - return ( - this.bold || - this.dim || - this.italic || - this.underline || - this.blink || - this.inverse || - this.hidden || - this.strikethrough || - this.fgColor !== null || - this.bgColor !== null - ); - } - - /** - * Get reset codes for attributes that need to be turned off at line end, - * specifically underline which bleeds into padding. - * Returns empty string if no problematic attributes are active. - */ - getLineEndReset(): string { - // Only underline causes visual bleeding into padding - // Other attributes like colors don't visually bleed to padding - if (this.underline) { - return "\x1b[24m"; // Underline off only - } - return ""; - } -} - const WRAP_OPTIONS = { wordWrap: true, hard: true, trim: false } as const; /** @@ -367,76 +174,7 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: * @returns Truncated text, optionally padded to exactly maxWidth */ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "…", pad: boolean = false): string { - const textVisibleWidth = visibleWidth(text); - - if (textVisibleWidth <= maxWidth) { - return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text; - } - - const ellipsisWidth = visibleWidth(ellipsis); - const targetWidth = maxWidth - ellipsisWidth; - - if (targetWidth <= 0) { - return ellipsis.substring(0, maxWidth); - } - - // Separate ANSI codes from visible content using grapheme segmentation - let i = 0; - const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; - - while (i < text.length) { - const ansiResult = extractAnsiCode(text, i); - if (ansiResult) { - segments.push({ type: "ansi", value: ansiResult.code }); - i += ansiResult.length; - } else { - // Find the next ANSI code or end of string - let end = i; - while (end < text.length) { - const nextAnsi = extractAnsiCode(text, end); - if (nextAnsi) break; - end++; - } - // Segment this non-ANSI portion into graphemes - const textPortion = text.slice(i, end); - for (const seg of segmenter.segment(textPortion)) { - segments.push({ type: "grapheme", value: seg.segment }); - } - i = end; - } - } - - // Build truncated string from segments - let result = ""; - let currentWidth = 0; - - for (const seg of segments) { - if (seg.type === "ansi") { - result += seg.value; - continue; - } - - const grapheme = seg.value; - // Skip empty graphemes to avoid issues with string-width calculation - if (!grapheme) continue; - - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > targetWidth) { - break; - } - - result += grapheme; - currentWidth += graphemeWidth; - } - - // Add reset code before ellipsis to prevent styling leaking into it - const truncated = `${result}\x1b[0m${ellipsis}`; - if (pad) { - const truncatedWidth = visibleWidth(truncated); - return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth)); - } - return truncated; + return nativeTruncateToWidth(text, maxWidth, ellipsis, pad); } /** @@ -455,49 +193,9 @@ export function sliceWithWidth( strict = false, ): { text: string; width: number } { if (length <= 0) return { text: "", width: 0 }; - const endCol = startCol + length; - let result = "", - resultWidth = 0, - currentCol = 0, - i = 0, - pendingAnsi = ""; - - while (i < line.length) { - const ansi = extractAnsiCode(line, i); - if (ansi) { - if (currentCol >= startCol && currentCol < endCol) result += ansi.code; - else if (currentCol < startCol) pendingAnsi += ansi.code; - i += ansi.length; - continue; - } - - let textEnd = i; - while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { - const w = visibleWidth(segment); - const inRange = currentCol >= startCol && currentCol < endCol; - const fits = !strict || currentCol + w <= endCol; - if (inRange && fits) { - if (pendingAnsi) { - result += pendingAnsi; - pendingAnsi = ""; - } - result += segment; - resultWidth += w; - } - currentCol += w; - if (currentCol >= endCol) break; - } - i = textEnd; - if (currentCol >= endCol) break; - } - return { text: result, width: resultWidth }; + return nativeSliceWithWidth(line, startCol, length, strict); } -// Pooled tracker instance for extractSegments (avoids allocation per call) -const pooledStyleTracker = new AnsiCodeTracker(); - /** * Extract "before" and "after" segments from a line in a single pass. * Used for overlay compositing where we need content before and after the overlay region. @@ -510,68 +208,5 @@ export function extractSegments( afterLen: number, strictAfter = false, ): { before: string; beforeWidth: number; after: string; afterWidth: number } { - let before = "", - beforeWidth = 0, - after = "", - afterWidth = 0; - let currentCol = 0, - i = 0; - let pendingAnsiBefore = ""; - let afterStarted = false; - const afterEnd = afterStart + afterLen; - - // Track styling state so "after" inherits styling from before the overlay - pooledStyleTracker.clear(); - - while (i < line.length) { - const ansi = extractAnsiCode(line, i); - if (ansi) { - // Track all SGR codes to know styling state at afterStart - pooledStyleTracker.process(ansi.code); - // Include ANSI codes in their respective segments - if (currentCol < beforeEnd) { - pendingAnsiBefore += ansi.code; - } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { - // Only include after we've started "after" (styling already prepended) - after += ansi.code; - } - i += ansi.length; - continue; - } - - let textEnd = i; - while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; - - for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { - const w = visibleWidth(segment); - - if (currentCol < beforeEnd) { - if (pendingAnsiBefore) { - before += pendingAnsiBefore; - pendingAnsiBefore = ""; - } - before += segment; - beforeWidth += w; - } else if (currentCol >= afterStart && currentCol < afterEnd) { - const fits = !strictAfter || currentCol + w <= afterEnd; - if (fits) { - // On first "after" grapheme, prepend inherited styling from before overlay - if (!afterStarted) { - after += pooledStyleTracker.getActiveCodes(); - afterStarted = true; - } - after += segment; - afterWidth += w; - } - } - - currentCol += w; - // Early exit: done with "before" only, or done with both segments - if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; - } - i = textEnd; - if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; - } - - return { before, beforeWidth, after, afterWidth }; + return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter); } diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts new file mode 100644 index 000000000..b85c061cd --- /dev/null +++ b/packages/tui/test/keys.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { Key, matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; + +describe("matchesKey", () => { + it("matches ctrl+letter sequences", () => { + setKittyProtocolActive(false); + const ctrlC = String.fromCharCode(3); + expect(matchesKey(ctrlC, Key.ctrl("c"))).toBe(true); + }); + + it("matches shifted tab", () => { + setKittyProtocolActive(false); + expect(matchesKey("\x1b[Z", "shift+tab")).toBe(true); + }); + + it("matches pageUp legacy sequence with mixed case keyId", () => { + setKittyProtocolActive(false); + expect(matchesKey("\x1b[5~", "pageUp")).toBe(true); + }); +}); diff --git a/packages/tui/test/text-utils.test.ts b/packages/tui/test/text-utils.test.ts new file mode 100644 index 000000000..1938475a6 --- /dev/null +++ b/packages/tui/test/text-utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { extractSegments, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; + +describe("text utils", () => { + it("computes visible width for ANSI and tabs", () => { + const text = `\x1b[31mhi\tthere\x1b[0m`; + expect(visibleWidth(text)).toBe(2 + 3 + 5); + }); + + it("ignores OSC hyperlinks in visible width", () => { + const text = "\x1b]8;;https://example.com\x07link\x1b]8;;\x07"; + expect(visibleWidth(text)).toBe(4); + }); + + it("truncates ANSI text with ellipsis", () => { + const text = "\x1b[31mhello world\x1b[0m"; + const result = truncateToWidth(text, 6, "…", false); + expect(result.includes("\x1b[0m…")).toBe(true); + expect(visibleWidth(result)).toBe(6); + }); + + it("slices visible columns while preserving ANSI", () => { + const text = "\x1b[31mhello\x1b[0m world"; + const result = sliceWithWidth(text, 1, 4, true); + expect(result.text.startsWith("\x1b[31mello")).toBe(true); + expect(result.width).toBe(4); + }); + + it("extracts segments with inherited styling", () => { + const text = "\x1b[31mhello world\x1b[0m"; + const result = extractSegments(text, 3, 6, 5, true); + expect(result.before).toContain("hel"); + expect(result.after.startsWith("\x1b[31m")).toBe(true); + expect(result.afterWidth).toBeGreaterThan(0); + }); +});