Draws Persian/Arabic text in Claude's replies joined and in right-to-left order on terminals that do no bidi or shaping. Terminal only; the transcript is…

A Claude Code mod that makes Persian/Arabic text in Claude's replies readable in terminals that do no bidi reordering or Arabic shaping (Windows Terminal, most Linux terminals).
It joins the letters (presentation forms, including پ چ ژ گ ک ی and lam-alef) and puts RTL lines in right-to-left order. English words, numbers and inline code stay in their own order. Code blocks are left alone. Long RTL lines are pre-wrapped to the terminal width so they read top to bottom.
Only the terminal drawing changes. The stored transcript and what the model sees stay the same. The desktop app is untouched, since it renders RTL itself.
/plugin install rtl --marketplace AliakbarNateghi/cc-rtl
Answer y to add the marketplace, then pick a scope.
On by default. /rtl off to turn it off, /rtl on to turn it back on, /rtl to see the state.
hooks/register.ts 174 lines1import type { Register } from 'claude-code'
2
3// ---- Arabic/Persian shaping: logical letters -> presentation forms ----
4
5// [isolated, final, initial, medial]; two forms = joins to the right only.
6const FORMS: Record<string, number[]> = {}
7// U+0621..U+064A map onto U+FE80.. in order; counts say how many forms each has.
8const BASIC = 'ءآأؤإئابةتثجحخدذرزسشصضطظعغفقكلمنهوىي'
9const COUNTS = [1, 2, 2, 2, 2, 4, 2, 4, 2, 4, 4, 4, 4, 4, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 4]
10let at = 0xfe80
11;[...BASIC].forEach((ch, i) => {
12 const n = COUNTS[i] ?? 1
13 FORMS[ch] = Array.from({ length: n }, (_, k) => at + k)
14 at += n
15})
16const seq = (start: number, n = 4) => Array.from({ length: n }, (_, k) => start + k)
17Object.assign(FORMS, {
18 'پ': seq(0xfb56), 'چ': seq(0xfb7a), 'ژ': seq(0xfb8a, 2),
19 'ک': seq(0xfb8e), 'گ': seq(0xfb92), 'ی': seq(0xfbfc),
20})
21// lam + alef ligatures: [isolated, final]
22const LAM_ALEF: Record<string, number[]> = { 'آ': [0xfef5, 0xfef6], 'أ': [0xfef7, 0xfef8], 'إ': [0xfef9, 0xfefa], 'ا': [0xfefb, 0xfefc] }
23const TATWEEL = 'ـ'
24const isMark = (c: string) => /[ً-ٰٟۖ-ۭ]/.test(c)
25const joinsBoth = (c?: string) => c === TATWEEL || (c !== undefined && (FORMS[c]?.length ?? 0) === 4)
26const joinsRight = (c?: string) => c === TATWEEL || (c !== undefined && (FORMS[c]?.length ?? 0) >= 2)
27
28type Unit = { s: string; opaque?: 'L' | 'R' }
29
30function shape(units: Unit[]): Unit[] {
31 const ch = (i: number) => (units[i]?.opaque ? undefined : units[i]?.s)
32 const near = (i: number, step: number) => {
33 let j = i + step
34 while (ch(j) !== undefined && isMark(ch(j)!)) j += step
35 return j
36 }
37 const out: Unit[] = []
38 for (let i = 0; i < units.length; i++) {
39 const c = ch(i)
40 const forms = c === undefined ? undefined : FORMS[c]
41 if (!forms) { out.push(units[i]!); continue }
42 const joinPrev = joinsRight(c) && joinsBoth(ch(near(i, -1)))
43 const n = near(i, 1)
44 if (c === 'ل' && n === i + 1 && LAM_ALEF[ch(n) ?? '']) {
45 out.push({ s: String.fromCharCode(LAM_ALEF[ch(n)!]![joinPrev ? 1 : 0]!) })
46 i = n
47 continue
48 }
49 const joinNext = forms.length === 4 && joinsRight(ch(n))
50 const k = joinPrev && joinNext ? 3 : joinPrev ? 1 : joinNext ? 2 : 0
51 out.push({ s: String.fromCharCode(forms[k] ?? forms[0]!) })
52 }
53 return out
54}
55
56// ---- bidi: logical -> visual order for one line (simplified UBA) ----
57
58const RTL_CHAR = /[-ٟ٪-ۯۺ-ࣿיִ-﷿ﹰ-]/
59const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', '«': '»', '»': '«' }
60const kind = (u: Unit): 'R' | 'L' | 'E' | 'N' =>
61 u.opaque ?? (RTL_CHAR.test(u.s) ? 'R' : /\p{Nd}/u.test(u.s) ? 'E' : /\p{L}/u.test(u.s) ? 'L' : 'N')
62
63function reorder(units: Unit[], rtl: boolean): Unit[] {
64 // strong direction per unit: numbers stay LTR inside RTL text
65 let lastStrong = rtl ? 'R' : 'L'
66 const dir = units.map(u => {
67 const k = kind(u)
68 if (k === 'R' || k === 'L') lastStrong = k
69 return k === 'E' ? (rtl ? 'L' : lastStrong === 'R' ? 'A' : 'L') : k
70 })
71 const strongAt = (i: number, step: number) => {
72 for (let j = i; j >= 0 && j < dir.length; j += step) if (dir[j] !== 'N') return dir[j] === 'A' ? 'R' : dir[j]
73 return rtl ? 'R' : 'L'
74 }
75 const levels = dir.map((d, i) => {
76 if (d === 'N') {
77 const a = strongAt(i, -1)
78 d = a === strongAt(i, 1) ? a! : rtl ? 'R' : 'L'
79 }
80 return d === 'R' ? 1 : d === 'A' ? 2 : rtl ? 2 : 0
81 })
82 const out = units.map((u, i) => (levels[i]! % 2 && !u.opaque && MIRROR[u.s] ? { s: MIRROR[u.s]! } : u))
83 for (let lv = Math.max(0, ...levels); lv >= 1; lv--) {
84 for (let i = 0; i < out.length; ) {
85 if (levels[i]! < lv) { i++; continue }
86 let j = i
87 while (j < out.length && levels[j]! >= lv) j++
88 out.splice(i, j - i, ...out.slice(i, j).reverse())
89 levels.splice(i, j - i, ...levels.slice(i, j).reverse())
90 i = j
91 }
92 }
93 return out
94}
95
96// ---- markdown-aware driver ----
97
98const TOKEN = /(`+)[\s\S]*?\1|(!?\[)([^\]]*)(\]\([^)\s]*\))|[\s\S]/gu
99
100function units(text: string): Unit[] {
101 return [...text.matchAll(TOKEN)].map(m => {
102 if (m[1]) return { s: m[0], opaque: 'L' as const } // inline code: untouched
103 if (m[2]) return { s: m[2] + line(m[3]!, 0) + m[4], opaque: RTL_CHAR.test(m[3]!) ? 'R' as const : 'L' as const }
104 return { s: m[0] }
105 })
106}
107
108const width = (u: Unit) => (u.opaque ? u.s.length : isMark(u.s) || u.s === '' ? 0 : 1)
109
110/** One markdown line to visual order; `cols` > 0 pre-wraps RTL lines so the terminal's own wrap never splits them. */
111function line(text: string, cols: number): string {
112 if (!RTL_CHAR.test(text)) return text
113 const prefix = /^\s*(?:(?:[-*+]|\d+[.)]|#{1,6}|>)\s+)*/.exec(text)![0]
114 const all = shape(units(text.slice(prefix.length)))
115 const count = (k: string) => all.filter(u => kind(u) === k).length
116 const rtl = count('R') > count('L')
117 const room = cols - prefix.length
118 let rows = [all]
119 if (rtl && cols > 0) {
120 // ponytail: greedy wrap at spaces; a word wider than the row overflows and the terminal wraps it
121 const words: Unit[][] = [[]]
122 for (const u of all) u.s === ' ' && !u.opaque ? words.push([]) : words.at(-1)!.push(u)
123 rows = [[]]
124 let used = 0
125 for (const w of words) {
126 const ww = w.reduce((a, u) => a + width(u), 0)
127 if (used > 0 && used + 1 + ww > room) { rows.push([]); used = 0 }
128 else if (rows.at(-1)!.length || used) { rows.at(-1)!.push({ s: ' ' }); used++ }
129 rows.at(-1)!.push(...w)
130 used += ww
131 }
132 }
133 const pad = ' '.repeat(prefix.length)
134 return rows.map((r, i) => (i ? pad : prefix) + reorder(r, rtl).map(u => u.s).join('')).join('\n')
135}
136
137/** Rewrites markdown so RTL text reads right on a terminal with no bidi/shaping. Code fences are left alone. */
138export function rtl(markdown: string, cols = 0): string {
139 let fence = ''
140 return markdown.split('\n').map(l => {
141 const f = /^\s*(`{3,}|~{3,})/.exec(l)?.[1]
142 if (f && (!fence || f[0] === fence[0] && f.length >= fence.length)) { fence = fence ? '' : f; return l }
143 return fence ? l : line(l, cols)
144 }).join('\n')
145}
146
147// ---- hooks ----
148
149const KEY = 'enabled'
150
151export const register: Register = on => {
152 on('session.start', async ($, e, next) => {
153 await $.command.register({ name: 'rtl', description: 'Persian/Arabic shaping in the terminal: on or off', argumentHint: 'on|off' })
154 return next(e)
155 })
156
157 on('command.run', { command: 'rtl' }, async ($, e) => {
158 const arg = e.args.trim().toLowerCase()
159 if (arg !== 'on' && arg !== 'off') {
160 const now = (await $.store.get(KEY)) !== false
161 return { text: `rtl is ${now ? 'on' : 'off'}. Use /rtl on or /rtl off.` }
162 }
163 await $.store.set(KEY, arg === 'on')
164 $.ui.invalidate('ui.render')
165 return { text: `rtl ${arg}` }
166 })
167
168 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
169 if (e.surface !== 'terminal' || (await $.store.get(KEY)) === false) return next(e)
170 const text = rtl(e.props.text, (e.viewport?.columns ?? 80) - 4)
171 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
172 })
173}
174