Displays Hebrew/Arabic (RTL) text correctly in your messages and Claude's replies

hooks/register.ts 22 lines1import type { Register } from 'claude-code'
2
3import { hasRtl, toVisual, withMarks } from './bidi'
4
5// Message rows sit under a two-cell gutter (bullet or prompt marker).
6const fixFor = (isMarks: boolean) => (text: string, viewport?: { columns: number }) =>
7 isMarks ? withMarks(text) : toVisual(text, viewport ? viewport.columns - 4 : 0)
8
9export const register: Register = (on, options) => {
10 const fix = fixFor(options?.mode === 'marks')
11
12 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
13 if (!hasRtl(e.props.text)) return next(e)
14 return next({ ...e, props: { ...e.props, text: fix(e.props.text, e.viewport) } })
15 })
16
17 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
18 if (!hasRtl(e.props.text)) return next(e)
19 return next({ ...e, props: { ...e.props, text: fix(e.props.text, e.viewport) } })
20 })
21}
22hooks/bidi.ts 122 lines1// Simplified Unicode bidi: reorders each line into visual order, for
2// terminals that draw characters strictly left to right.
3
4type Kind = 'R' | 'L' | 'N'
5
6const RTL = /[-ࣿיִ-﷿ﹰ-]/
7const LTR = /[A-Za-z0-9À-ɏЀ-ӿ]/
8const MIRROR: Record<string, string> = {
9 '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<',
10}
11const MD_PREFIX = /^(\s*(?:[-*+]|\d+[.)]|>+|#{1,6})\s+)/
12
13export const hasRtl = (s: string): boolean => RTL.test(s)
14
15const kindOf = (ch: string): Kind => (RTL.test(ch) ? 'R' : LTR.test(ch) ? 'L' : 'N')
16
17function reorderLine(line: string): string {
18 const chars = Array.from(line)
19 const kinds = chars.map(kindOf)
20 const first = kinds.find(k => k !== 'N')
21 const base: Kind = first === 'R' ? 'R' : 'L'
22 const baseLevel = base === 'R' ? 1 : 0
23
24 // Neutrals take the direction of their neighbours when both agree, else the base.
25 const resolved: Kind[] = kinds.slice()
26 for (let i = 0; i < chars.length; i++) {
27 if (kinds[i] !== 'N') continue
28 let j = i
29 while (j < chars.length && kinds[j] === 'N') j++
30 const before = i > 0 ? kinds[i - 1] : base
31 const after = j < chars.length ? kinds[j] : base
32 const dir = before === after ? before : base
33 for (let k = i; k < j; k++) resolved[k] = dir
34 i = j - 1
35 }
36
37 const levels = resolved.map((k, i) => {
38 if (k === 'R') return 1
39 // Latin letters and digits sit one level above an RTL base.
40 return base === 'R' ? 2 : 0
41 })
42 const out = chars.map((ch, i) => (levels[i] % 2 === 1 && MIRROR[ch] ? MIRROR[ch] : ch))
43
44 const max = Math.max(...levels)
45 const minOdd = Math.min(...levels.filter(l => l % 2 === 1), max + 1)
46 const order = chars.map((_, i) => i)
47 for (let lvl = max; lvl >= minOdd && lvl >= 1; lvl--) {
48 for (let i = 0; i < order.length; i++) {
49 if (levels[order[i]] < lvl) continue
50 let j = i
51 while (j < order.length && levels[order[j]] >= lvl) j++
52 const seg = order.slice(i, j).reverse()
53 order.splice(i, seg.length, ...seg)
54 i = j - 1
55 }
56 }
57 return order.map(i => out[i]).join('')
58}
59
60// Greedy word wrap, so each row can be reordered on its own.
61function wrap(text: string, width: number): string[] {
62 const rows: string[] = []
63 let row = ''
64 for (const word of text.split(' ')) {
65 let w = word
66 while (Array.from(w).length > width) {
67 if (row) { rows.push(row); row = '' }
68 const chars = Array.from(w)
69 rows.push(chars.slice(0, width).join(''))
70 w = chars.slice(width).join('')
71 }
72 if (!row) row = w
73 else if (Array.from(row).length + 1 + Array.from(w).length <= width) row += ' ' + w
74 else { rows.push(row); row = w }
75 }
76 rows.push(row)
77 return rows
78}
79
80export function toVisual(text: string, width = 0): string {
81 let inFence = false
82 return text
83 .split('\n')
84 .map(line => {
85 if (/^\s*(```|~~~)/.test(line)) {
86 inFence = !inFence
87 return line
88 }
89 if (inFence || !hasRtl(line)) return line
90 const prefix = MD_PREFIX.exec(line)?.[0] ?? ''
91 const body = line.slice(prefix.length)
92 const room = width - prefix.length
93 if (width <= 0 || room < 10 || Array.from(body).length <= room) {
94 return prefix + reorderLine(body)
95 }
96 const indent = ' '.repeat(Math.min(prefix.length, 3))
97 return wrap(body, room)
98 .map((r, i) => (i === 0 ? prefix : indent) + reorderLine(r))
99 .join('\n')
100 })
101 .join('\n')
102}
103
104const RLI = ''
105const PDI = ''
106
107export function withMarks(text: string): string {
108 let inFence = false
109 return text
110 .split('\n')
111 .map(line => {
112 if (/^\s*(```|~~~)/.test(line)) {
113 inFence = !inFence
114 return line
115 }
116 if (inFence || !hasRtl(line)) return line
117 const prefix = MD_PREFIX.exec(line)?.[0] ?? ''
118 return prefix + RLI + line.slice(prefix.length) + PDI
119 })
120 .join('\n')
121}
122