source dump of claude code
at main 139 lines 4.3 kB view raw
1/** 2 * Bidirectional text reordering for terminal rendering. 3 * 4 * Terminals on Windows do not implement the Unicode Bidi Algorithm, 5 * so RTL text (Hebrew, Arabic, etc.) appears reversed. This module 6 * applies the bidi algorithm to reorder ClusteredChar arrays from 7 * logical order to visual order before Ink's LTR cell placement loop. 8 * 9 * On macOS terminals (Terminal.app, iTerm2) bidi works natively. 10 * Windows Terminal (including WSL) does not implement bidi 11 * (https://github.com/microsoft/terminal/issues/538). 12 * 13 * Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost 14 * also lacks bidi. We enable bidi reordering when running on Windows or 15 * inside Windows Terminal (covers WSL). 16 */ 17import bidiFactory from 'bidi-js' 18 19type ClusteredChar = { 20 value: string 21 width: number 22 styleId: number 23 hyperlink: string | undefined 24} 25 26let bidiInstance: ReturnType<typeof bidiFactory> | undefined 27let needsSoftwareBidi: boolean | undefined 28 29function needsBidi(): boolean { 30 if (needsSoftwareBidi === undefined) { 31 needsSoftwareBidi = 32 process.platform === 'win32' || 33 typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal 34 process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js) 35 } 36 return needsSoftwareBidi 37} 38 39function getBidi() { 40 if (!bidiInstance) { 41 bidiInstance = bidiFactory() 42 } 43 return bidiInstance 44} 45 46/** 47 * Reorder an array of ClusteredChars from logical order to visual order 48 * using the Unicode Bidi Algorithm. Active on terminals that lack native 49 * bidi support (Windows Terminal, conhost, WSL). 50 * 51 * Returns the same array on bidi-capable terminals (no-op). 52 */ 53export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] { 54 if (!needsBidi() || characters.length === 0) { 55 return characters 56 } 57 58 // Build a plain string from the clustered chars to run through bidi 59 const plainText = characters.map(c => c.value).join('') 60 61 // Check if there are any RTL characters — skip bidi if pure LTR 62 if (!hasRTLCharacters(plainText)) { 63 return characters 64 } 65 66 const bidi = getBidi() 67 const { levels } = bidi.getEmbeddingLevels(plainText, 'auto') 68 69 // Map bidi levels back to ClusteredChar indices. 70 // Each ClusteredChar may be multiple code units in the joined string. 71 const charLevels: number[] = [] 72 let offset = 0 73 for (let i = 0; i < characters.length; i++) { 74 charLevels.push(levels[offset]!) 75 offset += characters[i]!.value.length 76 } 77 78 // Get reorder segments from bidi-js, but we need to work at the 79 // ClusteredChar level, not the string level. We'll implement the 80 // standard bidi reordering: find the max level, then for each level 81 // from max down to 1, reverse all contiguous runs >= that level. 82 const reordered = [...characters] 83 const maxLevel = Math.max(...charLevels) 84 85 for (let level = maxLevel; level >= 1; level--) { 86 let i = 0 87 while (i < reordered.length) { 88 if (charLevels[i]! >= level) { 89 // Find the end of this run 90 let j = i + 1 91 while (j < reordered.length && charLevels[j]! >= level) { 92 j++ 93 } 94 // Reverse the run in both arrays 95 reverseRange(reordered, i, j - 1) 96 reverseRangeNumbers(charLevels, i, j - 1) 97 i = j 98 } else { 99 i++ 100 } 101 } 102 } 103 104 return reordered 105} 106 107function reverseRange<T>(arr: T[], start: number, end: number): void { 108 while (start < end) { 109 const temp = arr[start]! 110 arr[start] = arr[end]! 111 arr[end] = temp 112 start++ 113 end-- 114 } 115} 116 117function reverseRangeNumbers(arr: number[], start: number, end: number): void { 118 while (start < end) { 119 const temp = arr[start]! 120 arr[start] = arr[end]! 121 arr[end] = temp 122 start++ 123 end-- 124 } 125} 126 127/** 128 * Quick check for RTL characters (Hebrew, Arabic, and related scripts). 129 * Avoids running the full bidi algorithm on pure-LTR text. 130 */ 131function hasRTLCharacters(text: string): boolean { 132 // Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F 133 // Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF 134 // Thaana: U+0780-U+07BF 135 // Syriac: U+0700-U+074F 136 return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test( 137 text, 138 ) 139}