SLOPSHOPPER

rtl-fix

Displays Hebrew/Arabic (RTL) text correctly in your messages and Claude's replies

newrows
★ 1v0.1.0no licenseupdated 2026-10-05CrAzyScreamx/claude-marketplace/mods/rtl-fix
A shopper browsing a rack in a slop shop
Source 2 files
hooks/register.ts 22 lines
1import type { Register } from 'claude-code'
2
3import { hasRtl, toVisual, withMarks } from './bidi'
4
5// Message rows sit under a two-cell gutter (bullet or prompt marker).
6const fixFor = (isMarks: boolean) => (text: string, viewport?: { columns: number }) =>
7  isMarks ? withMarks(text) : toVisual(text, viewport ? viewport.columns - 4 : 0)
8
9export const register: Register = (on, options) => {
10  const fix = fixFor(options?.mode === 'marks')
11
12  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
13    if (!hasRtl(e.props.text)) return next(e)
14    return next({ ...e, props: { ...e.props, text: fix(e.props.text, e.viewport) } })
15  })
16
17  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
18    if (!hasRtl(e.props.text)) return next(e)
19    return next({ ...e, props: { ...e.props, text: fix(e.props.text, e.viewport) } })
20  })
21}
22
hooks/bidi.ts 122 lines
1// Simplified Unicode bidi: reorders each line into visual order, for
2// terminals that draw characters strictly left to right.
3
4type Kind = 'R' | 'L' | 'N'
5
6const RTL = /[֐-ࣿיִ-﷿ﹰ-]/
7const LTR = /[A-Za-z0-9À-ɏЀ-ӿ]/
8const MIRROR: Record<string, string> = {
9  '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<',
10}
11const MD_PREFIX = /^(\s*(?:[-*+]|\d+[.)]|>+|#{1,6})\s+)/
12
13export const hasRtl = (s: string): boolean => RTL.test(s)
14
15const kindOf = (ch: string): Kind => (RTL.test(ch) ? 'R' : LTR.test(ch) ? 'L' : 'N')
16
17function reorderLine(line: string): string {
18  const chars = Array.from(line)
19  const kinds = chars.map(kindOf)
20  const first = kinds.find(k => k !== 'N')
21  const base: Kind = first === 'R' ? 'R' : 'L'
22  const baseLevel = base === 'R' ? 1 : 0
23
24  // Neutrals take the direction of their neighbours when both agree, else the base.
25  const resolved: Kind[] = kinds.slice()
26  for (let i = 0; i < chars.length; i++) {
27    if (kinds[i] !== 'N') continue
28    let j = i
29    while (j < chars.length && kinds[j] === 'N') j++
30    const before = i > 0 ? kinds[i - 1] : base
31    const after = j < chars.length ? kinds[j] : base
32    const dir = before === after ? before : base
33    for (let k = i; k < j; k++) resolved[k] = dir
34    i = j - 1
35  }
36
37  const levels = resolved.map((k, i) => {
38    if (k === 'R') return 1
39    // Latin letters and digits sit one level above an RTL base.
40    return base === 'R' ? 2 : 0
41  })
42  const out = chars.map((ch, i) => (levels[i] % 2 === 1 && MIRROR[ch] ? MIRROR[ch] : ch))
43
44  const max = Math.max(...levels)
45  const minOdd = Math.min(...levels.filter(l => l % 2 === 1), max + 1)
46  const order = chars.map((_, i) => i)
47  for (let lvl = max; lvl >= minOdd && lvl >= 1; lvl--) {
48    for (let i = 0; i < order.length; i++) {
49      if (levels[order[i]] < lvl) continue
50      let j = i
51      while (j < order.length && levels[order[j]] >= lvl) j++
52      const seg = order.slice(i, j).reverse()
53      order.splice(i, seg.length, ...seg)
54      i = j - 1
55    }
56  }
57  return order.map(i => out[i]).join('')
58}
59
60// Greedy word wrap, so each row can be reordered on its own.
61function wrap(text: string, width: number): string[] {
62  const rows: string[] = []
63  let row = ''
64  for (const word of text.split(' ')) {
65    let w = word
66    while (Array.from(w).length > width) {
67      if (row) { rows.push(row); row = '' }
68      const chars = Array.from(w)
69      rows.push(chars.slice(0, width).join(''))
70      w = chars.slice(width).join('')
71    }
72    if (!row) row = w
73    else if (Array.from(row).length + 1 + Array.from(w).length <= width) row += ' ' + w
74    else { rows.push(row); row = w }
75  }
76  rows.push(row)
77  return rows
78}
79
80export function toVisual(text: string, width = 0): string {
81  let inFence = false
82  return text
83    .split('\n')
84    .map(line => {
85      if (/^\s*(```|~~~)/.test(line)) {
86        inFence = !inFence
87        return line
88      }
89      if (inFence || !hasRtl(line)) return line
90      const prefix = MD_PREFIX.exec(line)?.[0] ?? ''
91      const body = line.slice(prefix.length)
92      const room = width - prefix.length
93      if (width <= 0 || room < 10 || Array.from(body).length <= room) {
94        return prefix + reorderLine(body)
95      }
96      const indent = ' '.repeat(Math.min(prefix.length, 3))
97      return wrap(body, room)
98        .map((r, i) => (i === 0 ? prefix : indent) + reorderLine(r))
99        .join('\n')
100    })
101    .join('\n')
102}
103
104const RLI = '⁧'
105const PDI = '⁩'
106
107export function withMarks(text: string): string {
108  let inFence = false
109  return text
110    .split('\n')
111    .map(line => {
112      if (/^\s*(```|~~~)/.test(line)) {
113        inFence = !inFence
114        return line
115      }
116      if (inFence || !hasRtl(line)) return line
117      const prefix = MD_PREFIX.exec(line)?.[0] ?? ''
118      return prefix + RLI + line.slice(prefix.length) + PDI
119    })
120    .join('\n')
121}
122