SLOPSHOPPER

hebrew-rtl

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

newbandrowsprompt
v0.1.0no licenseupdated 2026-10-06hemichaeli/hebrew-rtl
A shopper browsing a rack in a slop shop
README

hebrew-rtl

A Claude Code mod that shows Hebrew right-to-left and right-aligned, while code and English stay left-to-right.

What it does

  • Prompt mirror: Claude Code does not let a plugin redraw the prompt box itself, so while the draft contains Hebrew, a live mirror of it is drawn in the band above the prompt, laid out right-to-left.
  • Sent messages: your Hebrew prompts in the transcript are drawn right-to-left and right-aligned.
  • Per-line direction from the first strong letter (Unicode Bidirectional Algorithm): a line that starts in Hebrew is RTL, a line that starts in English stays LTR with any Hebrew word inside it in the right order.
  • Mixed text: English words, numbers (12:30, 1,000) and paths keep their order inside Hebrew sentences; brackets around Hebrew are mirrored.
  • Code stays LTR: inline ` code is one left-to-right unit, and fenced `` blocks are left untouched and left-aligned.
  • Per surface: on a plain terminal the mod puts characters in display order itself; on the desktop app and VS Code it adds Unicode isolates and lets the renderer do the ordering.

Install

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).

Option

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.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 3 files
hooks/register.tsx 85 lines
1import { 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}
85
hooks/bidi.ts 250 lines
1// 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}
250
types/index.d.ts 9 lines
1/** 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