Right-to-left (RTL) text for Claude Code: shows Persian (Farsi), Arabic and Hebrew paragraphs in the right order, with mixed English, numbers and punctuation…

<img src="assets/banner.svg" alt="Claude Code RTL: Persian, Arabic and Hebrew text before and after the plugin" width="100%">
<h1 align="center">Claude Code RTL</h1>
<b>Right-to-left (RTL) support for Claude Code.</b><br> Persian (Farsi), Arabic and Hebrew finally read in the right order, even when mixed with English, code, file names and numbers.
<a href="https://github.com/smhosseini72/claude-code-rtl/actions/workflows/ci.yml"><img src="https://github.com/smhosseini72/claude-code-rtl/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/smhosseini72/claude-code-rtl/releases"><img src="https://img.shields.io/github/v/release/smhosseini72/claude-code-rtl?color=8b5cf6" alt="Release"></a> <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-22d3ee" alt="macOS | Windows | Linux"> <a href="LICENSE"><img src="https://img.shields.io/github/license/smhosseini72/claude-code-rtl?color=22c55e" alt="MIT license"></a> <a href="https://github.com/smhosseini72/claude-code-rtl/stargazers"><img src="https://img.shields.io/github/stars/smhosseini72/claude-code-rtl?style=social" alt="GitHub stars"></a>
English · فارسی
Claude Code lays out every paragraph left-to-right. In a right-to-left language that breaks sentences in three ways:
config.json or npm install cuts the sentence in two and swaps the halves;All the words are there, but you end up reading them in pieces. Claude Code RTL fixes the order, so Claude's replies and your own prompts read like normal text.
/rtl, and your choice is rememberedRequires Claude Code v2.1.295 or newer (claude --version; update with claude update).
The same command works on macOS, Windows and Linux. In a Claude Code terminal session, type:
/plugin install rtl-text --marketplace smhosseini72/claude-code-rtl
Answer y to add the marketplace, then choose the user scope.
Or from any shell (Terminal, iTerm2, PowerShell, Windows Terminal, bash, zsh):
claude plugin install rtl-text --marketplace smhosseini72/claude-code-rtl
Using the Claude desktop app? Install once from a terminal as shown above. The app's Code tab loads the plugin in every new session.
# update to the latest version
claude plugin update rtl-text@claude-code-rtl
# uninstall
claude plugin uninstall rtl-text@claude-code-rtl
# install from a local clone (for development)
git clone https://github.com/smhosseini72/claude-code-rtl.git
claude plugin install rtl-text --marketplace ./claude-code-rtl
Nothing to configure: once installed, RTL paragraphs are fixed as they appear. To compare or switch off:
| Command | What it does |
|---|---|
/rtl | Toggle RTL display |
/rtl on | Turn it on |
/rtl off | Turn it off (handy for a before/after) |
| Where | Status |
|---|---|
| Claude desktop app (Code tab) | ✅ Works |
| VS Code extension | 🧪 Expected to work, not tested yet |
| Terminal (CLI) | ➖ Left alone: in a terminal, RTL depends on the terminal app's own bidi support |
flowchart LR
A["Claude's reply<br/>(markdown)"] --> B{"rtl-text<br/>ui.render hook"}
B -- "RTL paragraph" --> C["+ invisible U+2067<br/>after markdown markers"]
B -- "English / code" --> D["unchanged"]
C --> E["Drawn in the<br/>right order"]
D --> E
۱۲۳, ،, ؟). So a sentence like "PR ۱: merge شد" counts too, even though it opens in English. Code spans, URLs and link targets don't count.#, -, 1., >, [x]). After a colon or Persian comma that ends an English word it adds a RIGHT-TO-LEFT MARK (U+200F), so "PR ۱:" and "merge" stay two separate pieces instead of fusing into one English run. Signed numbers (-5, +2.5%) and phone numbers in spaced groups (+98 912 123 4567) go in a LEFT-TO-RIGHT ISOLATE (U+2066 … U+2069), so they don't come out as "5-" or with their groups reversed. A LEFT-TO-RIGHT MARK (U+200E) after + or # keeps "C++", "C#" and "Notepad++" from turning into "++C".Fenced code blocks are skipped, and lines that already start with a bidi mark are left as written. The source is short and readable: hooks/bidi.ts and hooks/register.ts.
Claude Code plugins are packages you install from a marketplace. They can add slash commands, skills, agents, MCP servers and hooks.
A mod is a plugin that ships a small TypeScript module which Claude Code runs inside itself, so it can change Claude Code's own interface: redraw a message, or add a panel, a status line or a command.
rtl-text is both. You install it like any plugin, and it works as a mod that redraws transcript messages. Mods are an early-access feature of Claude Code, so their API may change between releases.
text-align or dir.No. The plugin only rewrites the copy of the text that is about to be drawn. The stored conversation and the model's input are untouched.
It is about 190 lines of TypeScript with no dependencies. It makes no network requests, reads no files and collects nothing. All it stores is one on/off value for /rtl.
That is the right fix for a web page, but a mod can't set HTML attributes or CSS in Claude Code. A Unicode isolate gives the same reading order without patching or repackaging the app.
No. It does one quick text pass per message when the message is drawn.
claude plugin validate . # manifest, marketplace and hooks module
claude plugin test . # unit tests and render tests
.claude-plugin/
plugin.json plugin manifest
marketplace.json makes this repo installable with one command
hooks/
register.ts the mod: /rtl command and the ui.render hooks
bidi.ts direction detection and markdown-aware marking
tests/rtl.test.ts unit and render tests
types/index.d.ts state contract
Contributions are welcome, especially examples of text that still renders wrong. See CONTRIBUTING.md.
If Claude Code RTL made Claude readable in your language, please give the repo a star ⭐. It takes one click and helps other Persian, Arabic and Hebrew speakers find it. Sharing it with a friend or on social media helps too.
<a href="https://star-history.com/#smhosseini72/claude-code-rtl&Date"> <img src="https://api.star-history.com/svg?repos=smhosseini72/claude-code-rtl&type=Date" alt="Star history" width="600"> </a>
MIT © mahdi (@smhosseini72)
<sub><b>Keywords:</b> Claude Code RTL · Claude Code right-to-left · Claude Code Persian · Claude Code Farsi · Claude Code Arabic · Claude Code Hebrew · Claude desktop RTL · bidi · bidirectional text · Claude Code plugin · Claude Code mod · راستبهچپ کلاد کد · کلاد کد فارسی · Claude Code بالعربية · Claude Code בעברית</sub>
hooks/register.ts 58 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { markRtlMarkdown, markRtlPlain } from './bidi'
5
6const isOn = atom({ plugin: 'rtl-text', key: 'isOn' } as const, true)
7
8export const register: Register = on => {
9 on('session.start', async ($, e, next) => {
10 await $.command.register({
11 name: 'rtl',
12 description: 'Turn right-to-left display of Persian, Arabic and Hebrew on or off',
13 argumentHint: '[on|off]',
14 immediate: true,
15 })
16
17 const saved = await $.store.get('isOn')
18
19 if (typeof saved === 'boolean') {
20 await update($, isOn, () => saved)
21 }
22
23 return next(e)
24 })
25
26 on('command.run', { command: 'rtl' }, async ($, e) => {
27 const arg = e.args.trim().toLowerCase()
28 const want = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
29
30 await update($, isOn, () => want)
31 await $.store.set('isOn', want)
32
33 return { text: want ? 'RTL display is on.' : 'RTL display is off.' }
34 })
35
36 // The terminal draws cells in place, so an isolate there helps only where
37 // the terminal itself runs the bidi algorithm; it is left as the engine has it.
38 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
39 if (e.surface === 'terminal' || !(await read($, isOn))) {
40 return next(e)
41 }
42
43 const text = markRtlMarkdown(e.props.text)
44
45 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
46 })
47
48 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
49 if (e.surface === 'terminal' || !(await read($, isOn))) {
50 return next(e)
51 }
52
53 const text = markRtlPlain(e.props.text)
54
55 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
56 })
57}
58hooks/bidi.ts 210 lines1// Marks right-to-left lines of markdown with a Unicode isolate (RLI, U+2067).
2// A renderer that lays every block out left-to-right then still orders the
3// words, punctuation and embedded English of a Persian, Arabic or Hebrew
4// paragraph the way a reader expects. What the text says is unchanged.
5
6import type { Direction } from '../types'
7
8const LRI = '\u2066'
9const RLI = '\u2067'
10const PDI = '\u2069'
11const RLM = '\u200F'
12const LRM = '\u200E'
13
14const RTL_LETTER =
15 /[\u05D0-\u05F2\u0620-\u064A\u066E-\u06D5\u06EE\u06EF\u06FA-\u06FF\u0700-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFC]/
16const LTR_LETTER = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF]/
17
18// A line that already opens with an explicit bidi mark is left as written.
19const BIDI_MARK = /^[\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069]/
20
21// Arabic-script punctuation and digits turn up only in RTL prose.
22const RTL_SIGN = /[\u060C\u061B\u061F\u0660-\u0669\u06F0-\u06F9]/
23
24// What a reader does not read as words of the sentence: code spans, link
25// targets, bare URLs and HTML tags.
26const NOT_PROSE = /`[^`]*`|\]\([^)]*\)|https?:\/\/\S+|<[^>]+>/g
27
28// A colon or Arabic comma right after an English word or a digit joins it to
29// the English after it ("PR ۱: merge شد" draws as "شد PR ۱: merge"); an RLM
30// after it keeps them apart. It goes before a closing ** or _ so emphasis
31// still closes.
32const JOINING_PUNCTUATION =
33 /(?<=[A-Za-z0-9\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u0660-\u0669\u06F0-\u06F9])([:\u060C\u061B])(?=(?:\*{1,2}|_{1,2})?\s)/g
34// A number a reader takes as one left-to-right unit, which the bidi
35// algorithm splits in RTL text: a signed number ("-5" draws as "5-") and a
36// phone number in spaced groups ("+98 912 123 4567" draws reversed). Each
37// goes in a left-to-right isolate.
38const NUMBER_UNIT =
39 /(?<=^|[\s(])(?:\+[0-9\u0660-\u0669\u06F0-\u06F9]{1,4}(?:[ -][0-9\u0660-\u0669\u06F0-\u06F9]{1,5})+|[0-9\u0660-\u0669\u06F0-\u06F9]{2,}(?: [0-9\u0660-\u0669\u06F0-\u06F9]{2,}){2,}|[-+\u2212][0-9\u0660-\u0669\u06F0-\u06F9]+(?:[.,\u066B][0-9\u0660-\u0669\u06F0-\u06F9]+)*%?)/g
40
41// + and # that end an English word or a number ("C++", "C#", "18+") are
42// neutral, so in RTL text they jump to the word's other side ("++C"); an LRM
43// after them keeps them with it. It goes before a closing ** or _ too.
44const TRAILING_SYMBOLS =
45 /(?<=[A-Za-z0-9])([+#]+)(?=[\s.,;:!?)\]*_'"\u00AB\u00BB\u060C\u061B\u061F]|$)/g
46
47// What a fix never reaches into: code spans, link targets and URLs.
48const PROTECTED = /(`[^`]*`|\]\([^)]*\)|https?:\/\/\S+)/
49
50const FENCE = /^[ \t]*(`{3,}|~{3,})/
51const QUOTE = /^[ \t]*>[ \t]?/
52const LIST_ITEM = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/
53const HEADING = /^[ \t]*#{1,6}[ \t]+/
54const INDENT = /^[ \t]*/
55const TABLE_ROW = /^[ \t]*\|/
56const TABLE_CELL = /(\|[ \t]*)((?:\\\||`[^`]*`|[^|`])+)/g
57
58function directionOf(ch: string): Direction | undefined {
59 if (RTL_LETTER.test(ch)) {
60 return 'rtl'
61 }
62
63 return LTR_LETTER.test(ch) ? 'ltr' : undefined
64}
65
66function isDirection(dir: Direction | undefined): dir is Direction {
67 return dir !== undefined
68}
69
70function directionsOf(text: string): Direction[] {
71 return [...text].map(directionOf).filter(isDirection)
72}
73
74/**
75 * Whether a line reads right-to-left. Persian and Arabic sentences often open
76 * or close with an English word ("PR ۱: merge شد"), so once a line has RTL
77 * letters any one of these is enough: its first or last letter is RTL, at
78 * least half its words are RTL, or it uses Arabic-script digits or punctuation.
79 */
80export function isRtl(text: string): boolean {
81 const prose = text.replace(NOT_PROSE, ' ')
82 const letters = directionsOf(prose)
83
84 if (!letters.includes('rtl')) {
85 return false
86 }
87
88 const words = prose
89 .split(/\s+/)
90 .map(word => directionsOf(word)[0])
91 .filter(isDirection)
92 const rtlWords = words.filter(dir => dir === 'rtl').length
93
94 return (
95 letters[0] === 'rtl' ||
96 letters.at(-1) === 'rtl' ||
97 rtlWords * 2 >= words.length ||
98 RTL_SIGN.test(prose)
99 )
100}
101
102/**
103 * Splits a markdown line into its block markers (indent, `>`, list bullet,
104 * checkbox, `#`) and the text after them, where the isolate goes.
105 */
106function splitPrefix(line: string): [string, string] {
107 let at = 0
108
109 for (;;) {
110 const rest = line.slice(at)
111 const marker = QUOTE.exec(rest) ?? LIST_ITEM.exec(rest)
112
113 if (marker) {
114 at += marker[0].length
115 continue
116 }
117
118 at += (HEADING.exec(rest) ?? INDENT.exec(rest) ?? [''])[0].length
119
120 return [line.slice(0, at), line.slice(at)]
121 }
122}
123
124/**
125 * Keeps English and number runs where a reader expects them: an RLM after a
126 * colon or Arabic comma that ends an English word or a number, an LRM after
127 * "C++"-style symbols, and signed or phone numbers in a left-to-right
128 * isolate. Protected parts (code spans, link targets, URLs) land at odd
129 * indexes of the split and are left alone.
130 */
131function separateRuns(text: string): string {
132 return text
133 .split(PROTECTED)
134 .map((part, at) =>
135 at % 2 === 1
136 ? part
137 : part
138 .replace(JOINING_PUNCTUATION, `$1${RLM}`)
139 .replace(TRAILING_SYMBOLS, `$1${LRM}`)
140 .replace(NUMBER_UNIT, number => LRI + number + PDI),
141 )
142 .join('')
143}
144
145function mark(text: string, isClosed: boolean): string {
146 if (BIDI_MARK.test(text) || !isRtl(text)) {
147 return text
148 }
149
150 const body = text.trimEnd()
151 const tail = text.slice(body.length)
152
153 return RLI + separateRuns(body) + (isClosed ? PDI : '') + tail
154}
155
156function markLines(text: string, isClosed: boolean): string {
157 let fence: string | undefined
158
159 return text
160 .split('\n')
161 .map(line => {
162 const fenced = FENCE.exec(line)?.[1]
163
164 if (fence !== undefined) {
165 const isClose =
166 fenced !== undefined &&
167 fenced[0] === fence[0] &&
168 fenced.length >= fence.length &&
169 line.trim() === fenced
170
171 if (isClose) {
172 fence = undefined
173 }
174
175 return line
176 }
177
178 if (fenced !== undefined) {
179 fence = fenced
180
181 return line
182 }
183
184 if (TABLE_ROW.test(line)) {
185 return line.replace(TABLE_CELL, (_, open: string, cell: string) => open + mark(cell, true))
186 }
187
188 const [prefix, rest] = splitPrefix(line)
189
190 return prefix + mark(rest, isClosed)
191 })
192 .join('\n')
193}
194
195/**
196 * For a reply's markdown: each RTL line opens an isolate the block closes,
197 * so a paragraph written over several lines nests and still reads in order.
198 */
199export function markRtlMarkdown(text: string): string {
200 return markLines(text, false)
201}
202
203/**
204 * For text drawn line by line (a prompt as typed): each RTL line is its own
205 * closed isolate.
206 */
207export function markRtlPlain(text: string): string {
208 return markLines(text, true)
209}
210types/index.d.ts 8 lines1export type Direction = 'rtl' | 'ltr'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'rtl-text': { isOn: boolean }
6 }
7}
8