SLOPSHOPPER

rtl

Draws Persian/Arabic text in Claude's replies joined and in right-to-left order on terminals that do no bidi or shaping. Terminal only; the transcript is…

newrowscommand
v0.1.0MITupdated 2026-10-09AliakbarNateghi/cc-rtl
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rtl
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rtl ⎿ rtl: rtl is on. Use /rtl on or /rtl off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

rtl

A Claude Code mod that makes Persian/Arabic text in Claude's replies readable in terminals that do no bidi reordering or Arabic shaping (Windows Terminal, most Linux terminals).

It joins the letters (presentation forms, including پ چ ژ گ ک ی and lam-alef) and puts RTL lines in right-to-left order. English words, numbers and inline code stay in their own order. Code blocks are left alone. Long RTL lines are pre-wrapped to the terminal width so they read top to bottom.

Only the terminal drawing changes. The stored transcript and what the model sees stay the same. The desktop app is untouched, since it renders RTL itself.

Install

/plugin install rtl --marketplace AliakbarNateghi/cc-rtl

Answer y to add the marketplace, then pick a scope.

Use

On by default. /rtl off to turn it off, /rtl on to turn it back on, /rtl to see the state.

Limits

  • Lines are left-aligned, not right-aligned.
  • Only Claude's replies are changed, not your own prompt or tool output.
  • Text you copy from the terminal is the reordered form.
Source 1 files
hooks/register.ts 174 lines
1import type { Register } from 'claude-code'
2
3// ---- Arabic/Persian shaping: logical letters -> presentation forms ----
4
5// [isolated, final, initial, medial]; two forms = joins to the right only.
6const FORMS: Record<string, number[]> = {}
7// U+0621..U+064A map onto U+FE80.. in order; counts say how many forms each has.
8const BASIC = 'ءآأؤإئابةتثجحخدذرزسشصضطظعغفقكلمنهوىي'
9const COUNTS = [1, 2, 2, 2, 2, 4, 2, 4, 2, 4, 4, 4, 4, 4, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 4]
10let at = 0xfe80
11;[...BASIC].forEach((ch, i) => {
12  const n = COUNTS[i] ?? 1
13  FORMS[ch] = Array.from({ length: n }, (_, k) => at + k)
14  at += n
15})
16const seq = (start: number, n = 4) => Array.from({ length: n }, (_, k) => start + k)
17Object.assign(FORMS, {
18  'پ': seq(0xfb56), 'چ': seq(0xfb7a), 'ژ': seq(0xfb8a, 2),
19  'ک': seq(0xfb8e), 'گ': seq(0xfb92), 'ی': seq(0xfbfc),
20})
21// lam + alef ligatures: [isolated, final]
22const LAM_ALEF: Record<string, number[]> = { 'آ': [0xfef5, 0xfef6], 'أ': [0xfef7, 0xfef8], 'إ': [0xfef9, 0xfefa], 'ا': [0xfefb, 0xfefc] }
23const TATWEEL = 'ـ'
24const isMark = (c: string) => /[ً-ٰٟۖ-ۭ]/.test(c)
25const joinsBoth = (c?: string) => c === TATWEEL || (c !== undefined && (FORMS[c]?.length ?? 0) === 4)
26const joinsRight = (c?: string) => c === TATWEEL || (c !== undefined && (FORMS[c]?.length ?? 0) >= 2)
27
28type Unit = { s: string; opaque?: 'L' | 'R' }
29
30function shape(units: Unit[]): Unit[] {
31  const ch = (i: number) => (units[i]?.opaque ? undefined : units[i]?.s)
32  const near = (i: number, step: number) => {
33    let j = i + step
34    while (ch(j) !== undefined && isMark(ch(j)!)) j += step
35    return j
36  }
37  const out: Unit[] = []
38  for (let i = 0; i < units.length; i++) {
39    const c = ch(i)
40    const forms = c === undefined ? undefined : FORMS[c]
41    if (!forms) { out.push(units[i]!); continue }
42    const joinPrev = joinsRight(c) && joinsBoth(ch(near(i, -1)))
43    const n = near(i, 1)
44    if (c === 'ل' && n === i + 1 && LAM_ALEF[ch(n) ?? '']) {
45      out.push({ s: String.fromCharCode(LAM_ALEF[ch(n)!]![joinPrev ? 1 : 0]!) })
46      i = n
47      continue
48    }
49    const joinNext = forms.length === 4 && joinsRight(ch(n))
50    const k = joinPrev && joinNext ? 3 : joinPrev ? 1 : joinNext ? 2 : 0
51    out.push({ s: String.fromCharCode(forms[k] ?? forms[0]!) })
52  }
53  return out
54}
55
56// ---- bidi: logical -> visual order for one line (simplified UBA) ----
57
58const RTL_CHAR = /[֐-ٟ٪-ۯۺ-ࣿיִ-﷿ﹰ-]/
59const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', '«': '»', '»': '«' }
60const kind = (u: Unit): 'R' | 'L' | 'E' | 'N' =>
61  u.opaque ?? (RTL_CHAR.test(u.s) ? 'R' : /\p{Nd}/u.test(u.s) ? 'E' : /\p{L}/u.test(u.s) ? 'L' : 'N')
62
63function reorder(units: Unit[], rtl: boolean): Unit[] {
64  // strong direction per unit: numbers stay LTR inside RTL text
65  let lastStrong = rtl ? 'R' : 'L'
66  const dir = units.map(u => {
67    const k = kind(u)
68    if (k === 'R' || k === 'L') lastStrong = k
69    return k === 'E' ? (rtl ? 'L' : lastStrong === 'R' ? 'A' : 'L') : k
70  })
71  const strongAt = (i: number, step: number) => {
72    for (let j = i; j >= 0 && j < dir.length; j += step) if (dir[j] !== 'N') return dir[j] === 'A' ? 'R' : dir[j]
73    return rtl ? 'R' : 'L'
74  }
75  const levels = dir.map((d, i) => {
76    if (d === 'N') {
77      const a = strongAt(i, -1)
78      d = a === strongAt(i, 1) ? a! : rtl ? 'R' : 'L'
79    }
80    return d === 'R' ? 1 : d === 'A' ? 2 : rtl ? 2 : 0
81  })
82  const out = units.map((u, i) => (levels[i]! % 2 && !u.opaque && MIRROR[u.s] ? { s: MIRROR[u.s]! } : u))
83  for (let lv = Math.max(0, ...levels); lv >= 1; lv--) {
84    for (let i = 0; i < out.length; ) {
85      if (levels[i]! < lv) { i++; continue }
86      let j = i
87      while (j < out.length && levels[j]! >= lv) j++
88      out.splice(i, j - i, ...out.slice(i, j).reverse())
89      levels.splice(i, j - i, ...levels.slice(i, j).reverse())
90      i = j
91    }
92  }
93  return out
94}
95
96// ---- markdown-aware driver ----
97
98const TOKEN = /(`+)[\s\S]*?\1|(!?\[)([^\]]*)(\]\([^)\s]*\))|[\s\S]/gu
99
100function units(text: string): Unit[] {
101  return [...text.matchAll(TOKEN)].map(m => {
102    if (m[1]) return { s: m[0], opaque: 'L' as const } // inline code: untouched
103    if (m[2]) return { s: m[2] + line(m[3]!, 0) + m[4], opaque: RTL_CHAR.test(m[3]!) ? 'R' as const : 'L' as const }
104    return { s: m[0] }
105  })
106}
107
108const width = (u: Unit) => (u.opaque ? u.s.length : isMark(u.s) || u.s === '‌' ? 0 : 1)
109
110/** One markdown line to visual order; `cols` > 0 pre-wraps RTL lines so the terminal's own wrap never splits them. */
111function line(text: string, cols: number): string {
112  if (!RTL_CHAR.test(text)) return text
113  const prefix = /^\s*(?:(?:[-*+]|\d+[.)]|#{1,6}|>)\s+)*/.exec(text)![0]
114  const all = shape(units(text.slice(prefix.length)))
115  const count = (k: string) => all.filter(u => kind(u) === k).length
116  const rtl = count('R') > count('L')
117  const room = cols - prefix.length
118  let rows = [all]
119  if (rtl && cols > 0) {
120    // ponytail: greedy wrap at spaces; a word wider than the row overflows and the terminal wraps it
121    const words: Unit[][] = [[]]
122    for (const u of all) u.s === ' ' && !u.opaque ? words.push([]) : words.at(-1)!.push(u)
123    rows = [[]]
124    let used = 0
125    for (const w of words) {
126      const ww = w.reduce((a, u) => a + width(u), 0)
127      if (used > 0 && used + 1 + ww > room) { rows.push([]); used = 0 }
128      else if (rows.at(-1)!.length || used) { rows.at(-1)!.push({ s: ' ' }); used++ }
129      rows.at(-1)!.push(...w)
130      used += ww
131    }
132  }
133  const pad = ' '.repeat(prefix.length)
134  return rows.map((r, i) => (i ? pad : prefix) + reorder(r, rtl).map(u => u.s).join('')).join('\n')
135}
136
137/** Rewrites markdown so RTL text reads right on a terminal with no bidi/shaping. Code fences are left alone. */
138export function rtl(markdown: string, cols = 0): string {
139  let fence = ''
140  return markdown.split('\n').map(l => {
141    const f = /^\s*(`{3,}|~{3,})/.exec(l)?.[1]
142    if (f && (!fence || f[0] === fence[0] && f.length >= fence.length)) { fence = fence ? '' : f; return l }
143    return fence ? l : line(l, cols)
144  }).join('\n')
145}
146
147// ---- hooks ----
148
149const KEY = 'enabled'
150
151export const register: Register = on => {
152  on('session.start', async ($, e, next) => {
153    await $.command.register({ name: 'rtl', description: 'Persian/Arabic shaping in the terminal: on or off', argumentHint: 'on|off' })
154    return next(e)
155  })
156
157  on('command.run', { command: 'rtl' }, async ($, e) => {
158    const arg = e.args.trim().toLowerCase()
159    if (arg !== 'on' && arg !== 'off') {
160      const now = (await $.store.get(KEY)) !== false
161      return { text: `rtl is ${now ? 'on' : 'off'}. Use /rtl on or /rtl off.` }
162    }
163    await $.store.set(KEY, arg === 'on')
164    $.ui.invalidate('ui.render')
165    return { text: `rtl ${arg}` }
166  })
167
168  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
169    if (e.surface !== 'terminal' || (await $.store.get(KEY)) === false) return next(e)
170    const text = rtl(e.props.text, (e.viewport?.columns ?? 80) - 4)
171    return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
172  })
173}
174