SLOPSHOPPER

rtl-fix

Fixes Hebrew/English bidi text direction in transcript messages

newrowsprompt
v0.2.3MITupdated 2026-10-24EranTzarum/claude-rtl-fix
A shopper browsing a rack in a slop shop
README

<img src="docs/assets/logo.svg" alt="rtl-fix logo" width="112" height="112">

rtl-fix

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>

BeforeAfter
A Hebrew line that opens with EnglishThe words and punctuation jump aroundThe whole line reads right to left
git status inside a Hebrew sentenceLetters swap placesStays git status, left to right
A Hebrew line in a code blockGarbledRight to left, English kept in order
Steps like Authentication → Users → Send in a Hebrew lineArrows point backwardsOne left-to-right run, arrows point forward
Steps like Services ← Profiles ← developer.apple.comItems swap placesRead right to left, each item kept whole
Copying a fixed reply into your promptInvisible marks reach the modelRemoved before sending

Install

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.

Example

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.

How it works

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.

Develop

claude plugin validate .
claude plugin test .

The logic is one pure function, fixBidi, in hooks/bidi.ts. The hooks are in hooks/register.ts.

License

MIT

Source 2 files
hooks/register.ts 22 lines
1import 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}
22
hooks/bidi.ts 59 lines
1const 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