Right-to-left Hebrew in the Claude Code terminal (tested on Ubuntu GNOME Terminal): Hebrew paragraphs (by majority of letters) are drawn RTL and right-aligned…

A Claude Code mod that draws Hebrew right-to-left in the terminal.
/rtl toggles it.At the prompt of a Claude Code terminal session:
/plugin install hebrew-rtl-ubuntu --marketplace divonr/hebrew-rtl-ubuntu
Answer y to add the marketplace, then press Enter for the user scope. It's active right away.
Ubuntu 24.04, GNOME Terminal (VTE 0.76). That is the only setup it has been tested on.
Claude Code strips bidi control characters, so the mod can't simply mark a line as RTL. Instead it arranges each Hebrew line's characters so that the terminal's own bidi reordering produces the RTL picture. In principle it should work in any terminal that already draws Hebrew letters in the right order on its own (without reversing words), but that hasn't been verified anywhere else. In a terminal that doesn't reorder Hebrew at all, Hebrew letters will appear reversed. If you try it elsewhere, reports are welcome in the issues.
3 תפוחים) lands on the left.MIT
hooks/register.tsx 123 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { BOLD, CODE, ITALIC, LINK, STRIKE, hasHebrew, rtlRows, rtlTable, segment, spans } from './bidi'
5import type { Glyph } from './bidi'
6
7const isEnabled = atom({ plugin: 'hebrew-rtl-ubuntu', key: 'isEnabled' } as const, true)
8
9const GUTTER = 2
10
11function wants(surface: string, text: string): boolean {
12 return (
13 surface === 'terminal' &&
14 hasHebrew(text) &&
15 segment(text).some(s => s.kind === 'rtl' || (s.kind === 'table' && s.rtl))
16 )
17}
18
19// One terminal row of glyphs already arranged for the terminal's bidi pass,
20// drawn as styled spans side by side.
21function glyphRow(Box: any, Text: any, glyphs: Glyph[]) {
22 return (
23 <Box flexDirection="row" flexShrink={0}>
24 {spans(glyphs).map(span => (
25 <Text
26 bold={(span.style & BOLD) !== 0}
27 italic={(span.style & ITALIC) !== 0}
28 underline={(span.style & LINK) !== 0}
29 strikethrough={(span.style & STRIKE) !== 0}
30 color={span.style & CODE ? 'permission' : undefined}
31 >
32 {span.text}
33 </Text>
34 ))}
35 </Box>
36 )
37}
38
39// Draws a message body. `markdown` (a reply) draws LTR runs and LTR tables
40// through the engine's own Markdown; a prompt draws its text as typed.
41// RTL rows are right-aligned one per line; an RTL table is box-drawn.
42function body(Box: any, Text: any, Markdown: any, text: string, width: number, markdown: boolean) {
43 const line = (text: string) => (markdown ? <Markdown text={text} /> : <Text>{text}</Text>)
44
45 return (
46 <Box flexDirection="column" width={width}>
47 {segment(text).map(s => (
48 <Box flexDirection="column" marginTop={s.gap ? 1 : 0}>
49 {s.kind === 'rtl' &&
50 s.lines.flatMap(source =>
51 rtlRows(source, width, markdown).map(row => (
52 <Box justifyContent="flex-end" marginRight={row.indent} minHeight={1}>
53 {row.glyphs.length > 0 && glyphRow(Box, Text, row.glyphs)}
54 </Box>
55 )),
56 )}
57 {s.kind === 'ltr' && line(s.text)}
58 {s.kind === 'table' &&
59 (s.rtl ? (
60 <Box flexDirection="column" alignItems="flex-end">
61 {rtlTable(s.rows).map(row => glyphRow(Box, Text, row))}
62 </Box>
63 ) : (
64 line(s.source)
65 ))}
66 </Box>
67 ))}
68 </Box>
69 )
70}
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 await $.command.register({
75 name: 'rtl',
76 description: 'Toggle right-to-left layout for Hebrew paragraphs',
77 })
78
79 return next(e)
80 })
81
82 on('command.run', { command: 'rtl' }, async $ => {
83 const now = await update($, isEnabled, was => !was)
84
85 return { text: now ? 'RTL on: Hebrew paragraphs are right-aligned.' : 'RTL off.' }
86 })
87
88 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
89 if (e.props.isSummary || !wants(e.surface, e.props.text) || !(await read($, isEnabled))) {
90 return next(e)
91 }
92 const { Box, Text, Markdown } = $.ui.resolve(e)
93 const width = (e.viewport?.columns ?? 80) - GUTTER - 1
94
95 return (
96 <Box flexDirection="row">
97 <Box width={GUTTER} flexShrink={0}>
98 <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
99 </Box>
100 {body(Box, Text, Markdown, e.props.text, width, true)}
101 </Box>
102 )
103 })
104
105 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
106 const isPrompt = e.props.origin.kind === 'composer' || e.props.origin.kind === 'bridge'
107 if (!isPrompt || !wants(e.surface, e.props.text) || !(await read($, isEnabled))) {
108 return next(e)
109 }
110 const { Box, Text, Markdown } = $.ui.resolve(e)
111 const width = (e.viewport?.columns ?? 80) - GUTTER - 1
112
113 return (
114 <Box flexDirection="row" marginTop={1} backgroundColor="userMessageBackground">
115 <Box width={GUTTER} flexShrink={0}>
116 <Text dimColor>❯</Text>
117 </Box>
118 {body(Box, Text, Markdown, e.props.text, width, false)}
119 </Box>
120 )
121 })
122}
123hooks/bidi.ts 446 lines1// Pure text logic: deciding each line's direction and laying out RTL rows.
2//
3// The terminal (VTE) reorders every row with the Unicode bidi algorithm on an
4// LTR base, and Claude Code strips every bidi control character (RLM, RLE,
5// isolates). So an RTL row is drawn by arrangement alone: compute how the row
6// looks in a real RTL paragraph (`visual`, base RTL), then find the logical
7// string that the terminal's own LTR pass turns into exactly that picture
8// (`forTerminal`).
9
10const HEBREW = /[א-תװ-ײיִ-ﭏ]/g
11const LATIN = /[A-Za-zÀ-ɏ]/g
12const FENCE = /^\s*(```|~~~)/
13const TABLE_RULE = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
14
15// ---------------------------------------------------------------- direction
16
17/** RTL when Hebrew letters outnumber Latin ones; code, URLs and link targets don't vote. */
18export const isRtl = (text: string): boolean => {
19 const voting = text
20 .replace(/`[^`]*`/g, '')
21 .replace(/\]\([^)]*\)/g, ']')
22 .replace(/https?:\/\/\S+/g, '')
23 const hebrew = voting.match(HEBREW)?.length ?? 0
24 const latin = voting.match(LATIN)?.length ?? 0
25
26 return hebrew > latin
27}
28
29export const hasHebrew = (text: string): boolean => /[א-ת]/.test(text)
30
31// ---------------------------------------------------------------- the bidi algorithm
32
33type BidiClass = 'L' | 'R' | 'EN' | 'ES' | 'ET' | 'CS' | 'WS' | 'ON'
34
35/** A character with what follows it on the same cell (niqqud, accents). */
36export type Glyph = { ch: string; style: number }
37
38const R_CHAR = /[־׀׃׆א-תׯ-״יִײַ-ﭏ-ۿ]/u
39const MARK = /\p{M}/u
40const MIRROR: Record<string, string> = {
41 '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<',
42 '«': '»', '»': '«', '‹': '›', '›': '‹',
43}
44
45const classOf = (ch: string): BidiClass => {
46 const base = [...ch][0] ?? ' '
47 if (R_CHAR.test(base)) return 'R'
48 if (/[0-9]/.test(base)) return 'EN'
49 if (/[+\-]/.test(base)) return 'ES'
50 if (/[#$%°¢£¤¥₪€‰]/.test(base)) return 'ET'
51 if (/[,.:/ ]/.test(base)) return 'CS'
52 if (/\s/.test(base)) return 'WS'
53 if (/[\p{L}\p{M}]/u.test(base)) return 'L'
54
55 return 'ON'
56}
57
58/** Splits text into cells: a base character with its combining marks. */
59export const clusters = (text: string): string[] => {
60 const out: string[] = []
61 for (const ch of text) {
62 if (MARK.test(ch) && out.length > 0) out[out.length - 1] += ch
63 else out.push(ch)
64 }
65
66 return out
67}
68
69/** Embedding levels of one paragraph (rules W1–W7, N1–N2, I1–I2, L1). */
70const levels = (types: BidiClass[], base: 0 | 1): number[] => {
71 const t = [...types]
72 const n = t.length
73 const baseDir: BidiClass = base ? 'R' : 'L'
74 const strongBefore = (k: number): BidiClass => {
75 for (let j = k - 1; j >= 0; j--) if (t[j] === 'L' || t[j] === 'R') return t[j]!
76 return baseDir
77 }
78 // W4: a single separator between two numbers joins them.
79 for (let k = 1; k < n - 1; k++) {
80 if (t[k - 1] === 'EN' && t[k + 1] === 'EN' && (t[k] === 'ES' || t[k] === 'CS')) t[k] = 'EN'
81 }
82 // W5: currency and percent signs next to a number belong to it.
83 for (let k = 0; k < n; k++) {
84 if (t[k] !== 'ET') continue
85 let end = k
86 while (end < n && t[end] === 'ET') end++
87 if (t[k - 1] === 'EN' || t[end] === 'EN') for (let j = k; j < end; j++) t[j] = 'EN'
88 k = end - 1
89 }
90 // W6: what's left of the separators is neutral.
91 for (let k = 0; k < n; k++) if (t[k] === 'ES' || t[k] === 'ET' || t[k] === 'CS') t[k] = 'ON'
92 // W7: a number after Latin text reads as Latin.
93 for (let k = 0; k < n; k++) if (t[k] === 'EN' && strongBefore(k) === 'L') t[k] = 'L'
94 // N1, N2: neutrals take their neighbours' direction, or the paragraph's.
95 const dir = (c: BidiClass | undefined): 'L' | 'R' => (c === 'L' ? 'L' : 'R')
96 for (let k = 0; k < n; k++) {
97 if (t[k] !== 'ON' && t[k] !== 'WS') continue
98 let end = k
99 while (end < n && (t[end] === 'ON' || t[end] === 'WS')) end++
100 const before = k === 0 ? baseDir : dir(t[k - 1])
101 const after = end === n ? baseDir : dir(t[end])
102 const resolved = before === after ? before : baseDir
103 for (let j = k; j < end; j++) t[j] = resolved
104 k = end - 1
105 }
106 // I1, I2.
107 const out = t.map(c =>
108 base === 0 ? (c === 'R' ? 1 : c === 'EN' ? 2 : 0) : c === 'L' || c === 'EN' ? 2 : 1,
109 )
110 // L1: trailing whitespace sits at the paragraph's level.
111 for (let k = n - 1; k >= 0 && types[k] === 'WS'; k--) out[k] = base
112
113 return out
114}
115
116/** How a row of glyphs is drawn left to right in a paragraph of direction `base`. */
117export const visual = (row: Glyph[], base: 0 | 1): Glyph[] => {
118 const lv = levels(row.map(g => classOf(g.ch)), base)
119 const order = row.map((_, k) => k)
120 const top = Math.max(0, ...lv)
121 const lowestOdd = Math.min(...lv.filter(l => l % 2 === 1), top + 1)
122 for (let level = top; level >= lowestOdd; level--) {
123 for (let k = 0; k < order.length; k++) {
124 if ((lv[order[k]!] ?? 0) < level) continue
125 let end = k
126 while (end < order.length && (lv[order[end]!] ?? 0) >= level) end++
127 const run = order.slice(k, end).reverse()
128 order.splice(k, run.length, ...run)
129 k = end
130 }
131 }
132
133 return order.map(k => {
134 const g = row[k]!
135 const mirrored = (lv[k] ?? 0) % 2 === 1 ? MIRROR[g.ch] : undefined
136 return mirrored ? { ...g, ch: mirrored } : g
137 })
138}
139
140const NUMBER = /[#$%°¢£¤¥₪€‰]*[0-9]+(?:[.,:/][0-9]+)*[#$%°¢£¤¥₪€‰]*/g
141
142/**
143 * The logical row the terminal's LTR bidi pass draws as `picture`: each span
144 * of the picture that runs from a Hebrew letter to a Hebrew letter with no
145 * Latin letter inside is handed over reversed (numbers inside kept as they
146 * read, brackets swapped), since the terminal will reverse it back.
147 */
148export const forTerminal = (picture: Glyph[]): Glyph[] => {
149 const types = picture.map(g => classOf(g.ch))
150 const out = [...picture]
151 for (let k = 0; k < out.length; k++) {
152 if (types[k] !== 'R') continue
153 let end = k
154 for (let j = k + 1; j < out.length && types[j] !== 'L'; j++) if (types[j] === 'R') end = j
155 const span = picture.slice(k, end + 1).reverse()
156 const text = span.map(g => (g.ch.length === 1 ? g.ch : 'x')).join('')
157 for (const m of text.matchAll(NUMBER)) {
158 const at = m.index ?? 0
159 const number = span.slice(at, at + m[0].length).reverse()
160 span.splice(at, number.length, ...number)
161 }
162 out.splice(k, span.length, ...span.map(g => ({ ...g, ch: MIRROR[g.ch] ?? g.ch })))
163 k = end
164 }
165
166 return out
167}
168
169/** One RTL row, ready for the terminal. */
170export const rtlRow = (row: Glyph[]): Glyph[] => forTerminal(visual(row, 1))
171
172// ---------------------------------------------------------------- inline markdown
173
174export const BOLD = 1
175export const ITALIC = 2
176export const CODE = 4
177export const LINK = 8
178export const STRIKE = 16
179
180/** Inline markdown as styled glyphs: bold, italic, code, links, strikethrough. */
181export const inline = (text: string): Glyph[] => {
182 const out: Glyph[] = []
183 let style = 0
184 const chars = [...text]
185 for (let k = 0; k < chars.length; k++) {
186 const ch = chars[k]!
187 const next = chars[k + 1]
188 if (ch === '\\' && next !== undefined && /[\\`*_[\]()#+\-.!~|]/.test(next)) {
189 out.push({ ch: next, style })
190 k++
191 continue
192 }
193 if (ch === '`') {
194 const close = chars.indexOf('`', k + 1)
195 if (close > k) {
196 for (const c of chars.slice(k + 1, close)) out.push({ ch: c, style: style | CODE })
197 k = close
198 continue
199 }
200 }
201 if ((ch === '*' || ch === '_') && next === ch) {
202 style ^= BOLD
203 k++
204 continue
205 }
206 if (ch === '~' && next === '~') {
207 style ^= STRIKE
208 k++
209 continue
210 }
211 if (ch === '*' && next !== ' ') {
212 if ((style & ITALIC) || chars.indexOf('*', k + 1) > k) {
213 style ^= ITALIC
214 continue
215 }
216 }
217 if (ch === '[') {
218 const rest = chars.slice(k).join('')
219 const link = rest.match(/^\[([^\]]*)\]\(([^)]*)\)/)
220 if (link) {
221 for (const c of link[1]!) out.push({ ch: c, style: style | LINK })
222 k += [...link[0]].length - 1
223 continue
224 }
225 }
226 out.push({ ch, style })
227 }
228
229 // Marks join the cell before them.
230 const merged: Glyph[] = []
231 for (const g of out) {
232 const last = merged[merged.length - 1]
233 if (MARK.test(g.ch) && last) last.ch += g.ch
234 else merged.push({ ...g })
235 }
236
237 return merged
238}
239
240export const plainGlyphs = (text: string): Glyph[] => clusters(text).map(ch => ({ ch, style: 0 }))
241
242// ---------------------------------------------------------------- layout
243
244export type Segment =
245 | { kind: 'ltr'; text: string; gap: boolean }
246 | { kind: 'rtl'; lines: string[]; gap: boolean }
247 | { kind: 'table'; rtl: boolean; source: string; rows: string[][]; gap: boolean }
248
249const parseRow = (line: string): string[] =>
250 line
251 .trim()
252 .replace(/^\|/, '')
253 .replace(/\|$/, '')
254 .split(/(?<!\\)\|/)
255 .map(cell => cell.trim().replace(/\\\|/g, '|'))
256
257/**
258 * Splits text into runs. Every line (one Enter) is its own paragraph and
259 * votes alone; a code fence is always LTR; a table votes as a whole. `gap`
260 * marks a run that blank lines separate from the one before.
261 */
262export const segment = (text: string): Segment[] => {
263 const lines = text.split('\n')
264 const at = (k: number): string => lines[k] ?? ''
265 const out: Segment[] = []
266 let gap = false
267
268 const add = (kind: 'ltr' | 'rtl', chunk: string) => {
269 const last = out[out.length - 1]
270 if (last?.kind === 'rtl' && kind === 'rtl') {
271 if (gap) last.lines.push('')
272 last.lines.push(chunk)
273 } else if (last?.kind === 'ltr' && kind === 'ltr') {
274 last.text += (gap ? '\n\n' : '\n') + chunk
275 } else if (kind === 'rtl') {
276 out.push({ kind, lines: [chunk], gap: gap && out.length > 0 })
277 } else {
278 out.push({ kind, text: chunk, gap: gap && out.length > 0 })
279 }
280 gap = false
281 }
282
283 let i = 0
284 while (i < lines.length) {
285 const line = at(i)
286 if (FENCE.test(line)) {
287 const fence = line.trim().slice(0, 3)
288 const block = [line]
289 i++
290 while (i < lines.length) {
291 block.push(at(i))
292 i++
293 if (at(i - 1).trim().startsWith(fence)) break
294 }
295 add('ltr', block.join('\n'))
296 continue
297 }
298 if (line.trim() === '') {
299 gap = true
300 i++
301 continue
302 }
303 if (line.trim().startsWith('|') && TABLE_RULE.test(at(i + 1))) {
304 const block: string[] = []
305 while (i < lines.length && at(i).trim().startsWith('|')) {
306 block.push(at(i))
307 i++
308 }
309 const rows = block.filter((_, k) => k !== 1).map(parseRow)
310 out.push({
311 kind: 'table',
312 rtl: isRtl(rows.flat().join(' ')),
313 source: block.join('\n'),
314 rows,
315 gap: gap && out.length > 0,
316 })
317 gap = false
318 continue
319 }
320 add(isRtl(line) ? 'rtl' : 'ltr', line)
321 i++
322 }
323
324 return out
325}
326
327/** Greedy word wrap of glyphs to `width` cells; spaces at a break are dropped. */
328export const wrap = (glyphs: Glyph[], width: number): Glyph[][] => {
329 const words: Glyph[][] = []
330 let word: Glyph[] = []
331 for (const g of glyphs) {
332 if (g.ch === ' ') {
333 if (word.length) words.push(word)
334 word = []
335 } else word.push(g)
336 }
337 if (word.length) words.push(word)
338
339 const rows: Glyph[][] = []
340 let row: Glyph[] = []
341 for (const w of words) {
342 if (row.length === 0) row = [...w]
343 else if (row.length + 1 + w.length <= width) {
344 const space = { ch: ' ', style: (row[row.length - 1]!.style & w[0]!.style) }
345 row.push(space, ...w)
346 } else {
347 rows.push(row)
348 row = [...w]
349 }
350 }
351 if (row.length || rows.length === 0) rows.push(row)
352
353 return rows
354}
355
356export type RtlRow = { glyphs: Glyph[]; indent: number }
357
358/**
359 * One source line of RTL text as the rows it draws, each ready for the
360 * terminal: markdown block syntax (lists, headings, quotes) turned into
361 * glyphs that sit on the right, indentation moved to the right.
362 * `markdown` false keeps the line as typed.
363 */
364export const rtlRows = (line: string, width: number, markdown = true): RtlRow[] => {
365 if (line.trim() === '') return [{ glyphs: [], indent: 0 }]
366 const indent = line.match(/^\s*/)![0].replace(/\t/g, ' ').length
367 let body = line.trim()
368 let glyphs: Glyph[]
369 if (markdown) {
370 let style = 0
371 const heading = body.match(/^#{1,6}\s+(.*?)\s*#*$/)
372 if (heading) {
373 body = heading[1]!
374 style = BOLD
375 }
376 body = body
377 .replace(/^>\s?/, '│ ')
378 .replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, (_, box?: string) =>
379 box ? (box.trim() === '[ ]' ? '☐ ' : '☑ ') : '• ',
380 )
381 // A list number would be pulled to the wrong side by the terminal; a
382 // circled number is a symbol, and stays where it's put.
383 .replace(/^(\d+)[.)]\s+/, (m, d: string) => {
384 const n = Number(d)
385 return n >= 1 && n <= 20 ? String.fromCodePoint(0x2460 + n - 1) + ' ' : m
386 })
387 glyphs = inline(body).map(g => ({ ...g, style: g.style | style }))
388 } else {
389 glyphs = plainGlyphs(body)
390 }
391
392 return wrap(glyphs, Math.max(10, width - indent)).map(row => ({ glyphs: rtlRow(row), indent }))
393}
394
395/** Consecutive glyphs of one style, as the spans a row draws. */
396export const spans = (glyphs: Glyph[]): { text: string; style: number }[] => {
397 const out: { text: string; style: number }[] = []
398 for (const g of glyphs) {
399 const last = out[out.length - 1]
400 if (last && last.style === g.style) last.text += g.ch
401 else out.push({ text: g.ch, style: g.style })
402 }
403
404 return out
405}
406
407/**
408 * An RTL table as rows ready for the terminal: the picture is built as it
409 * should look (first column on the right, cells right-aligned, each cell's
410 * text in RTL order), then arranged for the terminal like any RTL row.
411 */
412export const rtlTable = (rows: string[][]): Glyph[][] => {
413 const columns = Math.max(...rows.map(r => r.length))
414 const grid = rows.map(r =>
415 Array.from({ length: columns }, (_, c) => visual(inline(r[c] ?? ''), 1)),
416 )
417 const widths = Array.from({ length: columns }, (_, c) =>
418 Math.max(1, ...grid.map(r => r[c]?.length ?? 0)),
419 )
420 const g = (text: string, style = 0): Glyph[] => plainGlyphs(text).map(x => ({ ...x, style }))
421 const mirrored = [...widths].reverse()
422 const rule = (left: string, mid: string, right: string) =>
423 g(left + mirrored.map(w => '─'.repeat(w + 2)).join(mid) + right)
424 const row = (cells: Glyph[][], style: number): Glyph[] => {
425 const picture: Glyph[] = g('│')
426 cells
427 .map((cell, c) => ({ cell, width: widths[c] ?? 0 }))
428 .reverse()
429 .forEach(({ cell, width }) => {
430 picture.push(...g(' '.repeat(width - cell.length + 1)))
431 picture.push(...cell.map(x => ({ ...x, style: x.style | style })))
432 picture.push(...g(' │'))
433 })
434 return forTerminal(picture)
435 }
436 const [header, ...body] = grid
437
438 return [
439 rule('┌', '┬', '┐'),
440 row(header ?? [], BOLD),
441 rule('├', '┼', '┤'),
442 ...body.map(r => row(r, 0)),
443 rule('└', '┴', '┘'),
444 ]
445}
446types/index.d.ts 8 lines1export type RtlEnabled = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'hebrew-rtl-ubuntu': { isEnabled: RtlEnabled }
6 }
7}
8