Fixes Hebrew/English bidi text direction in transcript messages

<img src="docs/assets/logo.svg" alt="rtl-fix logo" width="112" height="112">
Hebrew, English and mixed text in Claude Code, finally in the right order.
<a href="#install"><img src="https://img.shields.io/badge/Claude_Code-D97757?style=for-the-badge&logo=anthropic&logoColor=white" alt="Claude Code"></a> <a href="#install"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript"></a> <a href="#how-it-works"><img src="https://img.shields.io/badge/12_tests-passing-0F766E?style=for-the-badge" alt="12 tests passing"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-134E4A?style=for-the-badge" alt="MIT license"></a>
| Before | After | |
|---|---|---|
| A Hebrew line that opens with English | The words and punctuation jump around | The whole line reads right to left |
git status inside a Hebrew sentence | Letters swap places | Stays git status, left to right |
| A Hebrew line in a code block | Garbled | Right to left, English kept in order |
Steps like Authentication → Users → Send in a Hebrew line | Arrows point backwards | One left-to-right run, arrows point forward |
Steps like Services ← Profiles ← developer.apple.com | Items swap places | Read right to left, each item kept whole |
| Copying a fixed reply into your prompt | Invisible marks reach the model | Removed before sending |
In a terminal session of Claude Code, type at the prompt:
/plugin install rtl-fix --marketplace EranTzarum/claude-rtl-fix
Answer y to add the marketplace, then pick a scope. From a shell instead:
claude plugin install rtl-fix --marketplace EranTzarum/claude-rtl-fix
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with EranTzarum/claude-rtl-fix, then Add plugin.
Verify the install. Expected output:
Installed rtl-fix. Plugin is now active.
Then send a message such as הרץ את git status ובדוק ש-Claude Code עובד. The English words keep their order inside a right-to-left line.
Input line in a reply:
הרץ את git status.
What the mod hands to the screen (invisible marks shown as names):
<RLM>הרץ את <LRI>git status<PDI>.
The line has a right-to-left base, and git status is an isolated left-to-right run, so the dot lands where a Hebrew reader expects it.
The mod rewrites each Hebrew line before it is drawn, with an RTL base and isolated English runs. The desktop app draws the prompt box and your own message bubbles itself, so those are not covered. Details, and what it does not cover: docs/how-it-works.md.
claude plugin validate .
claude plugin test .
The logic is one pure function, fixBidi, in hooks/bidi.ts. The hooks are in hooks/register.ts.
hooks/register.ts 22 lines1import type { Register } from 'claude-code'
2
3import { fixBidi, unmarkDraft } from './bidi'
4
5export const register: Register = on => {
6 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
7 const text = fixBidi(e.props.text)
8 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
9 })
10
11 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
12 const text = fixBidi(e.props.text)
13 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
14 })
15
16 // The desktop prompt box ignores prompt.edit's answer, so it is not hooked. Marks copied from a drawn
17 // message are removed here: the model reads the prompt as typed
18 on('prompt.submit', ($, e, next) =>
19 e.command || unmarkDraft(e.text) === e.text ? next(e) : next({ ...e, text: unmarkDraft(e.text) }),
20 )
21}
22hooks/bidi.ts 59 lines1const RLM = '\u200F'
2const LRI = '\u2066'
3const RLI = '\u2067'
4const PDI = '\u2069'
5
6const HEBREW = /[\u0590-\u05FF]/
7const FIRST_STRONG = /[A-Za-z\u0590-\u05FF]/
8const MARKER = /^(\s*(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?)/
9const FENCE = /^\s*(```|~~~)/
10// Spans left as written: link targets, bare URLs. Inline code is isolated whole.
11const PROTECTED = /\]\([^)\s]*\)|https?:\/\/\S+/
12const CODE = /`[^`\n]*`/
13// From the first Latin letter or digit to the last one before Hebrew, so a chain like
14// `A → B → C` or `**A** / **B**` stays one left-to-right run and its arrows keep pointing forward.
15// A `←` ends a run: `A ← B ← C` is written to be read right to left, so each item is its own run
16const LATIN_RUN = /[A-Za-z0-9](?:[^\u0590-\u05FF\n←]*[A-Za-z0-9])?/g
17
18const isolateLatin = (s: string) => s.replace(LATIN_RUN, run => `${LRI}${run}${PDI}`)
19
20const fixSegment = (s: string): string => {
21 const hit = [CODE, PROTECTED]
22 .map(re => ({ re, m: re.exec(s) }))
23 .filter(x => x.m)
24 .sort((a, b) => a.m!.index - b.m!.index)[0]
25 if (!hit) return isolateLatin(s)
26 const m = hit.m!
27 const kept = hit.re === CODE ? `${LRI}${m[0]}${PDI}` : m[0]
28 return isolateLatin(s.slice(0, m.index)) + kept + fixSegment(s.slice(m.index + m[0].length))
29}
30
31/** Forces a right-to-left base on every line that holds Hebrew, and keeps embedded English runs in order. */
32export const fixBidi = (text: string): string => {
33 if (!HEBREW.test(text)) return text
34 let inFence = false
35 return text
36 .split('\n')
37 .map(line => {
38 if (FENCE.test(line)) {
39 inFence = !inFence
40 return line
41 }
42 if (!HEBREW.test(line)) return line
43 if (inFence) {
44 // A code line that opens with Hebrew is a sentence: RTL, Latin runs kept in order.
45 // One that opens with code (HTML, a command) stays left to right; its Hebrew strings read fine there.
46 const isHebrewFirst = HEBREW.test(FIRST_STRONG.exec(line)![0])
47 return isHebrewFirst ? RLI + isolateLatin(line) + PDI : line
48 }
49 // shortcut: table rows are left alone, a `|` row has no single base direction; revisit if tables garble
50 if (/^\s*\|/.test(line)) return line
51 const marker = MARKER.exec(line)![1]
52 return marker + RLM + fixSegment(line.slice(marker.length))
53 })
54 .join('\n')
55}
56
57/** The text with every bidi mark and isolate removed: marks copied from a drawn message. */
58export const unmarkDraft = (text: string): string => text.replace(/[\u200E\u200F\u2066-\u2069]/g, '')
59