Shows Hebrew in the prompt (live mirror above the box) and in sent messages right-to-left and right-aligned, keeping code and English left-to-right

A Claude Code mod that shows Hebrew right-to-left and right-aligned, while code and English stay left-to-right.
12:30, 1,000) and paths keep their order inside Hebrew sentences; brackets around Hebrew are mirrored. code is one left-to-right unit, and fenced `` blocks are left untouched and left-aligned.At the prompt of a Claude Code session:
/plugin install hebrew-rtl --marketplace hemichaeli/hebrew-rtl
Answer y to add the marketplace, then choose a scope (user scope is first; press Enter).
terminalBidi (in /config, default off): turn it on if your terminal already reorders bidi text itself (GNOME Terminal, Konsole, mlterm). The mod then only aligns, so the text is not reversed twice.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 85 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, ResolveInput } from 'claude-code'
3
4import type { Draft } from '../types'
5import { hasHebrew, layout } from './bidi'
6import type { Row } from './bidi'
7
8// The prompt box itself is drawn by the engine, so the draft is mirrored in
9// the band above it, laid out right-to-left whenever it holds Hebrew.
10const draft = atom({ plugin: 'hebrew-rtl', key: 'draft' } as const, '' as Draft)
11
12async function keepDraft($: EngineInterface, text: string): Promise<void> {
13 await update($, draft, () => text)
14}
15
16function drawRows($: EngineInterface, e: ResolveInput, rows: Row[], width: number) {
17 const { Box, Text } = $.ui.resolve(e)
18
19 return (
20 <Box flexDirection="column" width={width}>
21 {rows.map((row, i) => (
22 <Box key={`row-${i}`} width={width} justifyContent={row.dir === 'rtl' ? 'flex-end' : 'flex-start'}>
23 <Text dimColor={row.isCode} wrap="truncate-end">
24 {row.text === '' ? ' ' : row.text}
25 </Text>
26 </Box>
27 ))}
28 </Box>
29 )
30}
31
32export const register: Register = (on, options) => {
33 const isTerminalBidi = options.terminalBidi === true
34
35 // Only a plain terminal needs the characters put in display order; every
36 // other surface, and a bidi-aware terminal, orders them itself.
37 const reorderOn = (surface: RenderSurface): boolean => surface === 'terminal' && !isTerminalBidi
38
39 on('prompt.edit', async ($, e, next) => {
40 const box = await next(e)
41 await keepDraft($, box.text)
42
43 return box
44 })
45
46 on('prompt.fill', async ($, e, next) => {
47 const result = await next(e)
48 const box = await $.prompt.read()
49 await keepDraft($, box.text)
50
51 return result
52 })
53
54 on('prompt.submit', async ($, e, next) => {
55 await keepDraft($, '')
56
57 return next(e)
58 })
59
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 const text = await read($, draft)
62 if (e.props.hasSurvey || !hasHebrew(text)) {
63 return next(e)
64 }
65
66 const width = Math.max(10, e.props.bodyColumns)
67 const rows = layout(text, { width, reorder: reorderOn(e.surface) })
68 const room = Math.max(1, e.props.maxRows)
69
70 return drawRows($, e, rows.slice(-room), width)
71 })
72
73 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
74 const isPrompt = e.props.origin.kind === 'composer' || e.props.origin.kind === 'bridge'
75 if (!isPrompt || !hasHebrew(e.props.text)) {
76 return next(e)
77 }
78
79 const width = Math.max(10, (e.viewport?.columns ?? 80) - 4)
80 const rows = layout(e.props.text, { width, reorder: reorderOn(e.surface) })
81
82 return drawRows($, e, rows, width)
83 })
84}
85hooks/bidi.ts 250 lines1// A compact take on the Unicode Bidirectional Algorithm (UAX #9), enough for
2// Hebrew prose mixed with English, numbers and inline code: paragraph
3// direction from the first strong letter, weak and neutral resolution,
4// reordering of runs (L2) and mirroring of brackets (L4). Code spans and
5// fenced code blocks are held left-to-right as one unit.
6
7export type Dir = 'rtl' | 'ltr'
8
9type Kind = 'R' | 'L' | 'EN' | 'N'
10
11/** One grapheme-ish cell: a base character and the combining marks on it. */
12type Cell = { text: string; kind: Kind; isCode: boolean }
13
14const HEBREW = /[\u0590-\u05FF\uFB1D-\uFB4F]/
15const RTL_LETTER = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/
16const COMBINING = /[\u0300-\u036F\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED\u200C\u200D]/
17const LETTER = /\p{L}/u
18const DIGIT = /[0-9\u0660-\u0669\u06F0-\u06F9]/
19// Separators that stay inside a number when they sit between two digits.
20const NUMBER_JOINER = /[.,:/\-]/
21const FENCE = /^\s*(```|~~~)/
22
23const MIRROR: Record<string, string> = {
24 '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{',
25 '<': '>', '>': '<', '\u00AB': '\u00BB', '\u00BB': '\u00AB', '\u2039': '\u203A', '\u203A': '\u2039',
26}
27
28export const RLI = '\u2067'
29export const LRI = '\u2066'
30export const PDI = '\u2069'
31
32export const hasHebrew = (text: string): boolean => HEBREW.test(text)
33
34const kindOf = (ch: string): Kind => {
35 if (RTL_LETTER.test(ch)) return 'R'
36 if (DIGIT.test(ch)) return 'EN'
37 if (LETTER.test(ch)) return 'L'
38
39 return 'N'
40}
41
42/** Splits a line into cells, `code spans` marked as left-to-right units. */
43const toCells = (line: string): Cell[] => {
44 const cells: Cell[] = []
45 let isCode = false
46
47 for (const ch of Array.from(line)) {
48 if (ch === '`') {
49 isCode = !isCode
50 cells.push({ text: ch, kind: 'L', isCode: true })
51 continue
52 }
53 if (COMBINING.test(ch) && cells.length > 0) {
54 cells[cells.length - 1]!.text += ch
55 continue
56 }
57 cells.push({ text: ch, kind: isCode ? 'L' : kindOf(ch), isCode })
58 }
59
60 // Separators between two digits belong to the number (12:30, 3.5, 1,000).
61 for (let i = 1; i < cells.length - 1; i++) {
62 const cell = cells[i]!
63 if (cell.kind === 'N' && NUMBER_JOINER.test(cell.text) && cells[i - 1]!.kind === 'EN' && cells[i + 1]!.kind === 'EN') {
64 cell.kind = 'EN'
65 }
66 }
67
68 return cells
69}
70
71/** The direction of the first strong letter, `undefined` when there is none. */
72export const firstStrong = (line: string): Dir | undefined => {
73 for (const cell of toCells(line)) {
74 if (cell.kind === 'R') return 'rtl'
75 if (cell.kind === 'L') return 'ltr'
76 }
77
78 return undefined
79}
80
81/** Embedding level of every cell in a paragraph of direction `dir`. */
82const levelsOf = (cells: Cell[], dir: Dir): number[] => {
83 const base = dir === 'rtl' ? 'R' : 'L'
84 const resolved: Kind[] = cells.map(c => c.kind)
85
86 // W7: a number after a left-to-right letter (or at the start of an LTR
87 // paragraph) reads as left-to-right text.
88 let lastStrong: Kind = base
89 for (let i = 0; i < resolved.length; i++) {
90 const kind = resolved[i]
91 if (kind === 'L' || kind === 'R') lastStrong = kind
92 else if (kind === 'EN' && lastStrong === 'L') resolved[i] = 'L'
93 }
94
95 // N1/N2: a run of neutrals takes the direction of the text on both sides
96 // when they agree (numbers count as R), else the paragraph's.
97 const strongOf = (kind: Kind): 'L' | 'R' => (kind === 'L' ? 'L' : 'R')
98 let i = 0
99 while (i < resolved.length) {
100 if (resolved[i] !== 'N') {
101 i++
102 continue
103 }
104 let j = i
105 while (j < resolved.length && resolved[j] === 'N') j++
106 const before = i === 0 ? base : strongOf(resolved[i - 1]!)
107 const after = j === resolved.length ? base : strongOf(resolved[j]!)
108 const kind: Kind = before === after ? before : base
109 for (let k = i; k < j; k++) resolved[k] = kind
110 i = j
111 }
112
113 const levels = resolved.map(kind => {
114 if (dir === 'rtl') return kind === 'R' ? 1 : 2
115 if (kind === 'R') return 1
116 if (kind === 'EN') return 2
117
118 return 0
119 })
120
121 // L1: trailing whitespace goes back to the paragraph level.
122 const baseLevel = dir === 'rtl' ? 1 : 0
123 for (let k = cells.length - 1; k >= 0 && /^\s+$/.test(cells[k]!.text); k--) {
124 levels[k] = baseLevel
125 }
126
127 return levels
128}
129
130/**
131 * The line in display order, for a terminal that draws characters left to
132 * right as they come: runs reversed by level (L2), brackets in right-to-left
133 * runs mirrored (L4).
134 */
135export const toVisual = (line: string, dir: Dir): string => {
136 const cells = toCells(line)
137 const levels = levelsOf(cells, dir)
138 const order = cells.map((_, k) => k)
139 const top = Math.max(0, ...levels)
140
141 for (let level = top; level >= 1; level--) {
142 let k = 0
143 while (k < order.length) {
144 if (levels[order[k]!]! < level) {
145 k++
146 continue
147 }
148 let end = k
149 while (end < order.length && levels[order[end]!]! >= level) end++
150 const run = order.slice(k, end).reverse()
151 order.splice(k, run.length, ...run)
152 k = end
153 }
154 }
155
156 return order
157 .map(k => {
158 const cell = cells[k]!
159 const isMirrored = levels[k]! % 2 === 1 && !cell.isCode
160
161 return isMirrored ? (MIRROR[cell.text] ?? cell.text) : cell.text
162 })
163 .join('')
164}
165
166/**
167 * The line in logical order with isolates, for a surface that applies bidi
168 * itself (a desktop page, a bidi-aware terminal): an RTL line wrapped in
169 * RLI..PDI so its base direction is right-to-left, code spans in LRI..PDI.
170 */
171export const toIsolated = (line: string, dir: Dir): string => {
172 const withCode = line.replace(/`[^`]*`/g, span => `${LRI}${span}${PDI}`)
173
174 return dir === 'rtl' ? `${RLI}${withCode}${PDI}` : withCode
175}
176
177/** Display width in cells: combining marks take none. */
178export const widthOf = (text: string): number =>
179 Array.from(text).filter(ch => !COMBINING.test(ch)).length
180
181/** Breaks a logical line into pieces of at most `width` cells, at spaces. */
182export const wrapLogical = (line: string, width: number): string[] => {
183 if (width < 1 || widthOf(line) <= width) return [line]
184
185 const pieces: string[] = []
186 let rest = line
187 while (widthOf(rest) > width) {
188 const chars = Array.from(rest)
189 let cut = 0
190 let cells = 0
191 let lastSpace = -1
192 while (cut < chars.length) {
193 const add = COMBINING.test(chars[cut]!) ? 0 : 1
194 if (cells + add > width) break
195 cells += add
196 if (chars[cut] === ' ') lastSpace = cut
197 cut++
198 }
199 const at = lastSpace > 0 ? lastSpace : cut
200 pieces.push(chars.slice(0, at).join('').trimEnd())
201 rest = chars.slice(lastSpace > 0 ? at + 1 : at).join('')
202 }
203 pieces.push(rest)
204
205 return pieces
206}
207
208export type Row = { text: string; dir: Dir; isCode: boolean }
209
210export type LayoutOptions = {
211 /** Cells per row; pieces wider than this are wrapped. */
212 width: number
213 /** True: reorder for a plain terminal. False: logical text with isolates. */
214 reorder: boolean
215}
216
217/**
218 * A message laid out as rows: each line's direction from its first strong
219 * letter (a line with none follows the line before it, else the message's),
220 * fenced code blocks left-to-right and untouched.
221 */
222export const layout = (text: string, { width, reorder }: LayoutOptions): Row[] => {
223 const lines = text.split('\n')
224 const messageDir: Dir = firstStrong(lines.find(l => firstStrong(l)) ?? '') ?? 'ltr'
225 const rows: Row[] = []
226 let inFence = false
227 let previous: Dir = messageDir
228
229 for (const line of lines) {
230 if (FENCE.test(line)) {
231 inFence = !inFence
232 rows.push({ text: line, dir: 'ltr', isCode: true })
233 continue
234 }
235 if (inFence) {
236 rows.push({ text: line, dir: 'ltr', isCode: true })
237 continue
238 }
239
240 const dir = firstStrong(line) ?? previous
241 previous = dir
242 for (const piece of wrapLogical(line, width)) {
243 const shown = reorder ? toVisual(piece, dir) : toIsolated(piece, dir)
244 rows.push({ text: dir === 'rtl' ? shown.trim() : shown.trimEnd(), dir, isCode: false })
245 }
246 }
247
248 return rows
249}
250types/index.d.ts 9 lines1/** The prompt box's draft as last typed, mirrored above the box. */
2export type Draft = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'hebrew-rtl': { draft: Draft }
7 }
8}
9