Right-to-left for Claude Code mods: one file to copy into a mod that orders Hebrew and Arabic right on every surface, aligns wrapped text right, draws…

Right-to-left for Claude Code mods. One file you copy into a mod, so Hebrew and Arabic read right in a pane, a band or a status line.
A mod draws with Claude Code's own elements, and none of them takes a text direction. On the desktop app every element is laid out left to right, so a line that mixes Hebrew with English or numbers comes out scrambled, a long line wraps starting at the left, and Markdown is both. The terminal does no bidi at all and prints Hebrew reversed. The kit fixes each of these:
לבקש מ-Claude: 3 בקשות, 10:00–12:00 reads right, in text and in button labels.k.md() draws headings, paragraphs, nested lists, quotes and tables with the pieces above.Svg.claude plugin install rtl-kit@bar-mods
The plugin brings a skill, rtl-kit, that copies hooks/rtl.tsx into the mod you are building and uses it (with Read and Write: a shell cp out of ~/.claude/ is refused). By hand:
cp ~/.claude/plugins/marketplaces/bar-mods/rtl-kit/hooks/rtl.tsx <your-mod>/hooks/rtl.tsx
import { kit } from './rtl'
on('ui.render', { component: 'Pane', requestId: 'mine' }, async ($, e) => {
const k = kit($.ui.resolve(e), e)
return k.col('page', [
k.heading('h', 'שלום'),
k.para('p', 'טקסט ארוך בעברית עם Claude באמצע, שעובר כמה שורות ונשאר מיושר לימין.'),
k.row('buttons', [k.primary('ok', 'לאשר', () => approve($)), k.action('no', 'לדחות', () => decline($))]),
])
})
The copied file has no run-time imports and takes no $, so the mod needs nothing else installed. Re-copy it to upgrade; its first line names the version.
/rtl-gallery opens a gallery of every piece on the current surface, and a ruler to see how many characters fit a line.
Nothing. The gallery draws, remembers which tab is open, and reads Claude Code's theme setting. No files, processes, network or model calls. The copied rtl.tsx makes no calls at all.
From claude plugin validate:
session.start, command.run{command=rtl-gallery}, ui.render{component=Pane, requestId=rtl-kit}$.command.register, $.config.list, $.state.get, $.state.set, $.ui.open, $.ui.resolvecharsPerColumn changes it.theme setting describes the terminal, not the desktop app, so SVG on the desktop follows the system's colour scheme.Looked at in the desktop app's Code tab, every gallery tab. The terminal path is covered by the tests (both surfaces) and its reordering by expected strings; VS Code and the phone use the desktop's path and have not been looked at yet. In claude -p and the SDK its hooks run and nothing draws.
Tested on Claude Code 2.1.289.
hooks/register.tsx 163 lines1// rtl-kit's demo: /rtl-gallery opens a gallery of every piece of the kit on this surface, and a ruler
2// to measure how many characters fit a line. The kit itself is ./rtl.tsx, the file a mod copies.
3import { atom, read, update } from 'claude-code'
4import type { Register } from 'claude-code'
5
6import type { RtlKitDemo } from '../types'
7import { asStored, kit, schemeOf, svgHeader, svgTimeline, visual } from './rtl'
8
9const PANE = 'rtl-kit'
10const demo = atom({ plugin: 'rtl-kit', key: 'demo' } as const, { section: 'text', pressed: null } as RtlKitDemo)
11
12const SECTIONS = [
13 { id: 'text', label: 'טקסט' },
14 { id: 'parts', label: 'רכיבים' },
15 { id: 'md', label: 'Markdown' },
16 { id: 'svg', label: 'SVG' },
17 { id: 'terminal', label: 'טרמינל' },
18 { id: 'ruler', label: 'סרגל' },
19]
20
21// Invented sample text only.
22const MIXED = 'לבקש מ-Claude: 3 בקשות, 10:00–12:00'
23const LONG = 'פסקה ארוכה בעברית שעוברת כמה שורות: כל שורה צריכה להתחיל בצד ימין, גם כשיש בה מילה באנגלית כמו Claude, תאריך כמו 7.10.2026, או מספר כמו 42. בלי הערכה, השורה השנייה מתחילה משמאל.'
24const ARABIC = 'مرحبا: هذا سطر بالعربية مع Claude و 3 أرقام.'
25const SAMPLE_MD = `# נוהל לדוגמה
26
27פסקה ראשונה עם **הדגשה**, [קישור](https://example.com) ו-\`קוד\`, שמספיק ארוכה כדי לעבור שורה בחלונית צרה.
28
29## שלבים
30
311. לפתוח את החדר
322. לבדוק את הציוד:
33 - מגבות
34 - חיטוי
353. לסגור ביומן
36
37> הערה: זה ציטוט, והוא נראה ככרטיס.
38
39| פריט | מחיר | הערות |
40|---|---|---|
41| טיפול לדוגמה | 100 ₪ | כולל מע"מ |
42| מוצר \\| מיוחד | 25 ₪ | |
43`
44
45export const register: Register = on => {
46 on('session.start', async ($, e, next) => {
47 // Not /rtl-kit: the plugin's skill, rtl-kit, holds that name (a register refuses it).
48 await $.command.register({ name: 'rtl-gallery', description: 'Open the RTL kit gallery: every piece of the kit on this surface' })
49 return next(e)
50 })
51
52 // Opened without `focus`: a pane holding the keyboard can hold back queued prompts.
53 on('command.run', { command: 'rtl-gallery' }, async $ => {
54 const r = await $.ui.open({ id: PANE, title: 'rtl-kit', columns: 200 })
55 return { text: r.isPlaced ? 'rtl-kit is open.' : `rtl-kit did not open: ${r.reason}` }
56 })
57
58 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59 const el = $.ui.resolve(e)
60 const k = kit(el, e)
61 const d = await read($, demo)
62 // The theme setting is the terminal's; the desktop follows the system's colour scheme.
63 const scheme = e.surface === 'terminal' ? schemeOf((await $.config.list()).find(r => r.key === 'theme')?.value) : 'auto'
64 const pick = (id: string) => update($, demo, x => ({ ...x, section: id }))
65 const pressed = (what: string) => update($, demo, x => ({ ...x, pressed: what }))
66 const { Box, Text } = el
67
68 let body
69 if (d.section === 'text') {
70 body = k.col('text', [
71 k.heading('t:h', 'שורות מעורבות'),
72 k.text(MIXED),
73 k.text(ARABIC),
74 k.heading('t:h2', 'פסקה ארוכה', 2),
75 k.para('t:long', LONG),
76 k.heading('t:h3', 'כפתורים', 2),
77 k.row('t:btns', [
78 k.primary('t:primary', 'פעולה ראשית', () => pressed('primary')),
79 k.action('t:action', 'לבקש מ-Claude', () => pressed('action')),
80 k.quiet('t:quiet', 'ביטול', () => pressed('quiet')),
81 ]),
82 d.pressed ? k.muted('t:pressed', `נלחץ: ${d.pressed}`) : null,
83 ])
84 } else if (d.section === 'parts') {
85 body = k.col('parts', [
86 k.row('p:stats', [
87 k.stat('p:s1', 3, 'בקשות ממתינות', 'warn'),
88 k.stat('p:s2', 9, 'הזמנות היום', 'info'),
89 k.stat('p:s3', 2, 'באיחור', 'bad'),
90 ]),
91 k.card('p:card', {
92 title: 'חדר 4 · יום שני 5.10 · 10:00–12:00',
93 meta: 'בקשה של מטפלת לדוגמה. מתנגשת עם הזמנה אחת קיימת.',
94 tone: 'warn',
95 actions: [
96 k.primary('p:approve', 'לאשר', () => pressed('approve')),
97 k.action('p:decline', 'לדחות', () => pressed('decline')),
98 ],
99 }, [k.chips('p:chips', [{ label: 'בקשה', tone: 'warn' }, { label: 'מייל לשוכר', tone: 'info' }])]),
100 k.card('p:card2', { title: 'כרטיס שקט', meta: 'גבול עדין, ומודגש במעבר עכבר.' }),
101 k.heading('p:list', 'רשימה', 2),
102 k.list('p:l', ['פריט ראשון', { text: 'פריט מקונן עם Claude', depth: 1 }, 'פריט שלישי ארוך מספיק כדי לעבור שורה ולהראות שהשורה השנייה מיושרת לימין גם היא']),
103 k.heading('p:items', 'שורות ללחיצה', 2),
104 k.item('p:i1', 'הכנת שישי', () => pressed('item 1'), 'שבעה שלבים · נשמר להיום'),
105 k.item('p:i2', 'קביעת תור', () => pressed('item 2'), 'workflows/booking.md'),
106 k.heading('p:table', 'טבלה', 2),
107 k.table('p:t', ['שירות', 'מחיר', 'הערות'], [['טיפול לדוגמה', '100 ₪', 'כולל מע"מ'], ['ייעוץ', '180 ₪', 'הערה ארוכה שעוברת שורה בתוך התא']], { widths: [40, 20, 40] }),
108 d.pressed ? k.muted('p:pressed', `נלחץ: ${d.pressed}`) : null,
109 ])
110 } else if (d.section === 'md') {
111 body = k.md('md', SAMPLE_MD)
112 } else if (d.section === 'svg') {
113 if (k.isTerminal) {
114 body = k.para('svg:none', 'בטרמינל אין SVG: הכותרת והציר נשארים לחלונית בדסקטופ.')
115 } else {
116 const head = svgHeader({
117 title: 'חדרים · יום שני 5.10', subtitle: '3 בקשות ממתינות · 9 הזמנות · ניסוי', scheme, width: k.svgWidth,
118 chips: [{ label: '3 ממתינות', tone: 'warn' }, { label: '9 היום', tone: 'info' }, { label: '1 התנגשות', tone: 'bad' }],
119 })
120 const day = svgTimeline({
121 scheme, width: k.svgWidth, from: 8, to: 21, now: '13:20',
122 lanes: [{ id: 'r1', label: 'חדר 1' }, { id: 'r2', label: 'חדר 2' }, { id: 'r4', label: 'חדר 4' }, { id: 'hall', label: 'החלל הקהילתי' }],
123 blocks: [
124 { lane: 'r1', start: '09:00', end: '11:00', label: 'מטפלת א', tone: 'info' },
125 { lane: 'r2', start: '13:00', end: '14:00', label: 'מטפל ב', tone: 'info' },
126 { lane: 'r4', start: '10:00', end: '12:00', label: 'בקשה', tone: 'warn' },
127 { lane: 'r4', start: '16:00', end: '18:00', label: 'סגור: ניקיון', tone: 'muted' },
128 { lane: 'hall', start: '16:00', end: '20:00', label: 'קהילתי', tone: 'ok' },
129 ],
130 })
131 body = k.col('svg', [
132 k.svg('svg:head', head, 'כותרת: חדרים, יום שני'),
133 <Box key="svg:gap" height={1} />,
134 k.svg('svg:day', day, 'ציר זמן של החדרים היום'),
135 k.muted('svg:scheme', `ערכת צבעים: ${scheme} (לפי ערכת הצבעים של המערכת)`),
136 ])
137 }
138 } else if (d.section === 'terminal') {
139 const lines = [MIXED, ARABIC, 'סוגריים (בתוך) משפט · 10:00–12:00']
140 body = k.col('term', [
141 k.para('term:what', 'כך נשלח לטרמינל: הסדר החזותי מחושב, כי הטרמינל מדפיס תווים לפי הסדר השמור. כאן זה מוצג בדיוק כפי שהטרמינל ידפיס, ובטרמינל זה ייקרא נכון.'),
142 ...lines.map((l, i) => <Text key={`term:${i}`}>{k.isTerminal ? visual(l) : asStored(visual(l))}</Text>),
143 ])
144 } else {
145 const ruler = (n: number) => Array.from({ length: n }, (_, i) => String((i + 1) % 10)).join('')
146 body = k.col('ruler', [
147 k.para('r:what', `bodyColumns ${k.cols} · רוחב השבירה ${k.width} תווים. השורה הארוכה ביותר שלא נשברת היא היחס הנכון.`),
148 <Text key="r:w">{ruler(k.width)}</Text>,
149 <Text key="r:c">{ruler(k.cols)}</Text>,
150 <Text key="r:c11">{ruler(Math.round(k.cols * 1.1))}</Text>,
151 <Text key="r:he">{k.bidi('א'.repeat(k.cols))}</Text>,
152 ])
153 }
154
155 return (
156 <Box flexDirection="column">
157 {k.tabs('tabs', SECTIONS, d.section, pick)}
158 {body}
159 </Box>
160 )
161 })
162}
163hooks/rtl.tsx 569 lines1// rtl-kit 0.1.1: right-to-left text and a small design language for Claude Code mods.
2// https://github.com/barmoshe/bar-mods/tree/main/rtl-kit
3//
4// Copy this one file into your mod's hooks/ folder and import from './rtl'. It imports nothing at
5// run time, takes no `$`, and works on every surface:
6//
7// const k = kit($.ui.resolve(e), e)
8// return k.col('page', [k.heading('h', 'שלום'), k.para('p', 'טקסט ארוך בעברית עם Claude באמצע…')])
9//
10// Why it exists (measured on the desktop app, Claude Code 2.1.288-2.1.289):
11// - every element is laid out left to right, so a line that mixes Hebrew with Latin or digits is
12// scrambled; wrapping it in RLE…PDF fixes the order, in a Text and in a Button label;
13// - a line that wraps still starts at the left, so long text is wrapped here, one Text per line,
14// each aligned right;
15// - Markdown is scrambled and left-aligned, so md() renders markdown with these builders instead;
16// - the terminal does no bidi at all: Hebrew comes out reversed, so the terminal gets the visual
17// order computed here.
18//
19// Never name a variable `h` in a .tsx file: JSX compiles to the global h().
20
21// ---------- characters and lines ----------
22
23export type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile' | string
24
25const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/
26const LATIN_CHAR = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF]/
27const DIGIT = /[0-9\u0660-\u0669\u06F0-\u06F9]/
28const MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g
29const RLE = '\u202B'
30const PDF = '\u202C'
31
32export const hasRtl = (s: string) => RTL_CHAR.test(s)
33// Shows a string exactly in stored order on a surface that does bidi (a preview of terminal output).
34export const asStored = (s: string) => `\u202D${s}\u202C`
35export const stripMarks = (s: string) => s.replace(MARKS, '')
36
37export type BidiOptions = {
38 // 'visual' (the default) reorders right-to-left text for a terminal that draws characters in
39 // stored order; 'logical' leaves it for a terminal that does its own bidi.
40 terminal?: 'visual' | 'logical'
41}
42
43// What to hand an element so a right-to-left string reads right on this surface.
44export function bidi(s: string, surface: Surface, opts: BidiOptions = {}): string {
45 if (!hasRtl(s)) return s
46 if (surface === 'terminal') {
47 return opts.terminal === 'logical' ? s : s.split('\n').map(visual).join('\n')
48 }
49 return `${RLE}${stripMarks(s)}${PDF}`
50}
51
52const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', '«': '»', '»': '«' }
53
54type Dir = 'R' | 'L' | 'N'
55
56function strong(ch: string): Dir {
57 if (RTL_CHAR.test(ch)) return 'R'
58 if (LATIN_CHAR.test(ch) || DIGIT.test(ch)) return 'L'
59 return 'N'
60}
61
62// One line in the order a terminal must print it so it reads right to left. A simplified Unicode
63// bidi: Latin words and numbers stay left to right (with what sits between them, so "Claude: 3"
64// and "10:00–12:00" stay whole), everything else follows the line's direction, set by its first
65// letter. Brackets in right-to-left runs are mirrored. Combining marks are not handled.
66export function visual(line: string): string {
67 const chars = Array.from(stripMarks(line))
68 if (!chars.some(c => RTL_CHAR.test(c))) return chars.join('')
69 const dirs = chars.map(strong)
70 // The line's direction comes from its first letter (digits are weak, as in Unicode's bidi).
71 const firstLetter = chars.find(c => RTL_CHAR.test(c) || LATIN_CHAR.test(c)) ?? ''
72 const base: Dir = LATIN_CHAR.test(firstLetter) ? 'L' : 'R'
73 // A neutral between two left-to-right characters joins them; any other neutral takes the base.
74 const resolved = dirs.map((d, i) => {
75 if (d !== 'N') return d
76 let p = i - 1
77 while (p >= 0 && dirs[p] === 'N') p--
78 let q = i + 1
79 while (q < dirs.length && dirs[q] === 'N') q++
80 const before = p >= 0 ? dirs[p] : null
81 const after = q < dirs.length ? dirs[q] : null
82 return before === 'L' && after === 'L' ? 'L' : base
83 })
84 const runs: { dir: Dir; text: string[] }[] = []
85 chars.forEach((c, i) => {
86 const d = resolved[i] ?? base
87 const last = runs[runs.length - 1]
88 if (last && last.dir === d) last.text.push(c)
89 else runs.push({ dir: d, text: [c] })
90 })
91 const flip = (r: { dir: Dir; text: string[] }) =>
92 r.dir === 'R' ? [...r.text].reverse().map(c => MIRROR[c] ?? c).join('') : r.text.join('')
93 return (base === 'R' ? [...runs].reverse() : runs).map(flip).join('')
94}
95
96// Word-wrap to `width` characters; a word longer than the width is cut.
97export function wrap(s: string, width: number): string[] {
98 const w = Math.max(8, Math.floor(width))
99 const out: string[] = []
100 for (const para of s.split('\n')) {
101 let line = ''
102 for (const word of para.split(/\s+/).filter(Boolean)) {
103 let rest = word
104 while (Array.from(rest).length > w) {
105 if (line) {
106 out.push(line)
107 line = ''
108 }
109 out.push(Array.from(rest).slice(0, w).join(''))
110 rest = Array.from(rest).slice(w).join('')
111 }
112 if (!line) line = rest
113 else if (Array.from(line).length + 1 + Array.from(rest).length <= w) line = `${line} ${rest}`
114 else {
115 out.push(line)
116 line = rest
117 }
118 }
119 out.push(line)
120 }
121 return out
122}
123
124// ---------- the design language ----------
125
126// Theme keys, so colours follow the app's light and dark themes.
127export const tone = {
128 accent: 'claude',
129 ok: 'success',
130 warn: 'warning',
131 bad: 'error',
132 info: 'permission',
133 muted: 'secondaryText',
134 line: 'subtle',
135 fill: 'userMessageBackground',
136} as const
137export type Tone = keyof typeof tone
138
139// ---------- markdown, parsed ----------
140
141export type MdBlock =
142 | { kind: 'heading'; level: number; text: string }
143 | { kind: 'para'; text: string }
144 | { kind: 'item'; depth: number; marker: string; text: string }
145 | { kind: 'quote'; text: string }
146 | { kind: 'table'; headers: string[]; rows: string[][] }
147 | { kind: 'code'; text: string }
148 | { kind: 'rule' }
149
150// Inline markdown to plain text: links to their text, emphasis and code marks off.
151export function inline(s: string): string {
152 return s
153 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
154 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
155 .replace(/\*\*(.+?)\*\*/g, '$1')
156 .replace(/__(.+?)__/g, '$1')
157 .replace(/~~(.+?)~~/g, '$1')
158 .replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1$2')
159 .replace(/`([^`]*)`/g, '$1')
160 .replace(/\s+/g, ' ')
161 .trim()
162}
163
164// A table line to its cells: a pipe splits unless escaped (\|) or inside a code span.
165export function cells(line: string): string[] {
166 const out: string[] = []
167 let cur = ''
168 let isCode = false
169 const s = line.trim()
170 for (let i = 0; i < s.length; i++) {
171 const ch = s[i]
172 if (ch === '\\' && s[i + 1] === '|') {
173 cur += '|'
174 i++
175 continue
176 }
177 if (ch === '`') isCode = !isCode
178 if (ch === '|' && !isCode) {
179 out.push(cur)
180 cur = ''
181 continue
182 }
183 cur += ch
184 }
185 out.push(cur)
186 if (s.startsWith('|')) out.shift()
187 if (s.endsWith('|') && !s.endsWith('\\|')) out.pop()
188 return out.map(c => c.trim())
189}
190
191const SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
192
193export function parseMd(src: string): MdBlock[] {
194 let lines = src.replace(/\r/g, '').split('\n')
195 if (lines[0]?.trim() === '---') {
196 const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
197 if (end > 0) lines = lines.slice(end + 1)
198 }
199 const out: MdBlock[] = []
200 let para: string[] = []
201 const flush = () => {
202 if (para.length) out.push({ kind: 'para', text: inline(para.join(' ')) })
203 para = []
204 }
205 for (let i = 0; i < lines.length; i++) {
206 const l = lines[i] ?? ''
207 if (/^\s*```/.test(l)) {
208 flush()
209 const code: string[] = []
210 for (i++; i < lines.length && !/^\s*```/.test(lines[i] ?? ''); i++) code.push(lines[i] ?? '')
211 out.push({ kind: 'code', text: code.join('\n') })
212 continue
213 }
214 if (l.trim() === '') {
215 flush()
216 continue
217 }
218 const hd = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l)
219 if (hd) {
220 flush()
221 out.push({ kind: 'heading', level: (hd[1] ?? '#').length, text: inline(hd[2] ?? '') })
222 continue
223 }
224 if (/^\s*(-{3,}|\*{3,})\s*$/.test(l)) {
225 flush()
226 out.push({ kind: 'rule' })
227 continue
228 }
229 if (l.trim().startsWith('|') && SEPARATOR.test(lines[i + 1] ?? '')) {
230 flush()
231 const headers = cells(l).map(inline)
232 const rows: string[][] = []
233 for (i += 2; i < lines.length && (lines[i] ?? '').trim().startsWith('|'); i++) {
234 if (!SEPARATOR.test(lines[i] ?? '')) rows.push(cells(lines[i] ?? '').map(inline))
235 }
236 i--
237 out.push({ kind: 'table', headers, rows })
238 continue
239 }
240 const q = /^\s*>\s?(.*)$/.exec(l)
241 if (q) {
242 flush()
243 const quote = [q[1] ?? '']
244 while (i + 1 < lines.length && /^\s*>/.test(lines[i + 1] ?? '')) quote.push((lines[++i] ?? '').replace(/^\s*>\s?/, ''))
245 out.push({ kind: 'quote', text: inline(quote.join(' ')) })
246 continue
247 }
248 const it = /^(\s*)(\d+[.)]|[-*+])\s+(?:\[[ xX]\]\s+)?(.*)$/.exec(l)
249 if (it) {
250 flush()
251 const indent = (it[1] ?? '').replace(/\t/g, ' ').length
252 const raw = it[2] ?? '-'
253 out.push({ kind: 'item', depth: Math.min(3, Math.floor(indent / 2)), marker: /\d/.test(raw) ? raw.replace(')', '.') : '•', text: inline(it[3] ?? '') })
254 continue
255 }
256 const last = out[out.length - 1]
257 if (para.length === 0 && last?.kind === 'item' && /^\s{2,}\S/.test(l)) {
258 last.text = `${last.text} ${inline(l)}`
259 continue
260 }
261 para.push(l.trim())
262 }
263 flush()
264 return out
265}
266
267// ---------- SVG pieces (strings for el.Svg; not drawn on the terminal) ----------
268
269export type Scheme = 'light' | 'dark' | 'auto'
270
271export const escapeXml = (s: string) =>
272 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
273
274const PALETTE = {
275 light: { bg: '#faf9f5', panel: '#f0eee6', text: '#141413', muted: '#6b6a64', grid: '#e3e0d6', accent: '#d97757', info: '#6a9bcc', ok: '#788c5d', warn: '#d4a017', bad: '#c4554d', line: '#b0aea5', fill: '#e8e6dc', onTone: '#ffffff' },
276 dark: { bg: '#262624', panel: '#30302e', text: '#f5f4ef', muted: '#a8a69e', grid: '#3d3d3a', accent: '#d97757', info: '#7fb0de', ok: '#94ab72', warn: '#e0b33a', bad: '#d9746c', line: '#6b6a64', fill: '#3a3a37', onTone: '#141413' },
277}
278type Paint = keyof typeof PALETTE.light
279
280// Fills by class, so 'auto' can switch with the viewer's colour scheme.
281function styleFor(scheme: Scheme): string {
282 const rules = (p: typeof PALETTE.light) =>
283 (Object.keys(p) as Paint[]).map(k => `.f-${k}{fill:${p[k]}}.s-${k}{stroke:${p[k]}}`).join('')
284 if (scheme === 'light') return `<style>${rules(PALETTE.light)}</style>`
285 if (scheme === 'dark') return `<style>${rules(PALETTE.dark)}</style>`
286 return `<style>${rules(PALETTE.light)}@media (prefers-color-scheme: dark){${rules(PALETTE.dark)}}</style>`
287}
288
289const FONT = `font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, 'Noto Sans Hebrew', 'Noto Sans Arabic', sans-serif"`
290const clampWidth = (w: number | undefined) => Math.max(280, Math.min(720, Math.round(w ?? 520)))
291// A rough text width, enough to size chips and cut labels: Hebrew and Arabic letters are narrower
292// than Latin capitals, digits in between.
293export const textWidth = (s: string, size: number) => Array.from(s).reduce((sum, c) => sum + (c === ' ' ? 0.3 : /[A-Z]/.test(c) ? 0.66 : 0.56) * size, 0)
294const toneFill = (t: Tone | undefined): Paint =>
295 t === 'ok' ? 'ok' : t === 'warn' ? 'warn' : t === 'bad' ? 'bad' : t === 'info' ? 'info' : t === 'muted' || t === 'line' ? 'line' : 'accent'
296
297function fitText(s: string, size: number, max: number): string {
298 if (textWidth(s, size) <= max) return s
299 const chars = Array.from(s)
300 while (chars.length > 1 && textWidth(`${chars.join('')}…`, size) > max) chars.pop()
301 return `${chars.join('')}…`
302}
303
304export type SvgChip = { label: string; tone?: Tone }
305
306export function svgHeader(o: { title: string; subtitle?: string; chips?: SvgChip[]; scheme?: Scheme; width?: number }): { source: string; width: number; height: number } {
307 const width = clampWidth(o.width)
308 const chips = o.chips ?? []
309 const height = chips.length ? 118 : 78
310 const right = width - 22
311 let x = right
312 const chipSvg = chips.map(c => {
313 const w = Math.ceil(textWidth(c.label, 12)) + 22
314 const left = x - w
315 const out = `<rect class="f-${toneFill(c.tone)}" x="${left}" y="80" width="${w}" height="24" rx="12"/><text class="f-onTone" x="${x - 11}" y="96" direction="rtl" font-size="12" font-weight="600">${escapeXml(c.label)}</text>`
316 x = left - 8
317 return left > 10 ? out : ''
318 }).join('')
319 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ${FONT}>${styleFor(o.scheme ?? 'auto')}
320<rect class="f-bg" width="${width}" height="${height}" rx="14"/>
321<rect class="f-accent" x="${width - 6}" y="16" width="4" height="${o.subtitle ? 44 : 26}" rx="2"/>
322<text class="f-text" x="${right}" y="38" direction="rtl" font-size="20" font-weight="700">${escapeXml(fitText(o.title, 20, width - 44))}</text>
323${o.subtitle ? `<text class="f-muted" x="${right}" y="62" direction="rtl" font-size="13">${escapeXml(fitText(o.subtitle, 13, width - 44))}</text>` : ''}
324${chipSvg}</svg>`
325 return { source, width, height }
326}
327
328export type Lane = { id: string; label: string }
329export type Block = { lane: string; start: string; end: string; label?: string; tone?: Tone }
330
331const minutes = (hm: string) => Number(hm.slice(0, 2)) * 60 + Number(hm.slice(3, 5))
332
333// Lanes (rooms, people) against the hours of one day, read right to left: the first hour at the
334// right edge. Blocks outside the hours are clipped.
335export function svgTimeline(o: { lanes: Lane[]; blocks: Block[]; from?: number; to?: number; now?: string; scheme?: Scheme; width?: number }): { source: string; width: number; height: number } {
336 const width = clampWidth(o.width)
337 const from = o.from ?? 8
338 const to = Math.max(from + 1, o.to ?? 21)
339 const labelW = 86
340 const top = 28
341 const laneH = 26
342 const height = top + o.lanes.length * laneH + 12
343 const plotRight = width - labelW - 10
344 const plotLeft = 14
345 const span = (to - from) * 60
346 const xAt = (m: number) => plotRight - ((Math.min(Math.max(m, from * 60), to * 60) - from * 60) / span) * (plotRight - plotLeft)
347 const ticks: string[] = []
348 const step = to - from > 8 ? 2 : 1
349 for (let hr = from; hr <= to; hr += step) {
350 const x = xAt(hr * 60)
351 ticks.push(`<line class="s-grid" x1="${x}" y1="${top - 6}" x2="${x}" y2="${height - 10}" stroke-width="1"/><text class="f-muted" x="${x}" y="16" text-anchor="middle" font-size="10">${hr}</text>`)
352 }
353 const lanes = o.lanes.map((l, i) => {
354 const y = top + i * laneH
355 return `<rect class="f-panel" x="${plotLeft}" y="${y + 3}" width="${plotRight - plotLeft}" height="${laneH - 6}" rx="6"/><text class="f-text" x="${width - 12}" y="${y + laneH / 2 + 4}" direction="rtl" font-size="12" font-weight="600">${escapeXml(fitText(l.label, 12, labelW - 6))}</text>`
356 }).join('')
357 const blocks = o.blocks.map(b => {
358 const i = o.lanes.findIndex(l => l.id === b.lane)
359 if (i < 0) return ''
360 const y = top + i * laneH
361 const x1 = xAt(minutes(b.end))
362 const x2 = xAt(minutes(b.start))
363 const w = x2 - x1
364 if (w <= 1) return ''
365 const label = b.label ? fitText(b.label, 11, w - 10) : ''
366 return `<rect class="f-${toneFill(b.tone)}" x="${x1 + 1}" y="${y + 4}" width="${w - 2}" height="${laneH - 8}" rx="5"/>${label && w > 26 ? `<text class="f-onTone" x="${x2 - 6}" y="${y + laneH / 2 + 4}" direction="rtl" font-size="11" font-weight="600">${escapeXml(label)}</text>` : ''}`
367 }).join('')
368 const now = o.now ? (() => {
369 const m = minutes(o.now)
370 if (m < from * 60 || m > to * 60) return ''
371 const x = xAt(m)
372 return `<line class="s-bad" x1="${x}" y1="${top - 4}" x2="${x}" y2="${height - 10}" stroke-width="2"/>`
373 })() : ''
374 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ${FONT}>${styleFor(o.scheme ?? 'auto')}
375<rect class="f-bg" width="${width}" height="${height}" rx="14"/>${ticks.join('')}${lanes}${blocks}${now}</svg>`
376 return { source, width, height }
377}
378
379// Claude Code's `theme` setting, from `(await $.config.list()).find(r => r.key === 'theme')?.value`.
380// It is the terminal's theme: the desktop app can be light while it says dark (seen 05/10), so on
381// the desktop leave the scheme 'auto' and let the system's colour scheme decide.
382export function schemeOf(theme: unknown): Scheme {
383 const t = typeof theme === 'string' ? theme : ''
384 return t.includes('dark') ? 'dark' : t.includes('light') ? 'light' : 'auto'
385}
386
387// ---------- the builders ----------
388
389// The element constructors are the engine's; the kit only passes props through.
390// eslint-disable-next-line @typescript-eslint/no-explicit-any
391type Els = Record<string, any>
392// eslint-disable-next-line @typescript-eslint/no-explicit-any
393type Node = any
394type Press = () => unknown
395
396export type KitOptions = BidiOptions & {
397 // How many characters fit in one of the pane's columns, for wrapping long text yourself
398 // (measured with the /rtl-gallery ruler; proportional fonts make this an estimate).
399 charsPerColumn?: number
400}
401
402export type Kit = ReturnType<typeof kit>
403
404export function kit(el: Els, e: { surface: Surface; props?: { bodyColumns?: number } }, opts: KitOptions = {}) {
405 const { Box, Text, Button } = el
406 const surface = e.surface
407 const isTerminal = surface === 'terminal'
408 const cols = e.props?.bodyColumns ?? 80
409 const width = Math.max(16, Math.floor(cols * (opts.charsPerColumn ?? (isTerminal ? 1 : 0.9))))
410 const b = (s: string) => bidi(s, surface, opts)
411 // The press hands back what the handler returns, so the engine (and a test's press) can wait for
412 // it; dropping the promise lets work run on after the press and its errors go unhandled.
413 const run = (f: Press) => () => f()
414
415 const text = (s: string, props: Record<string, unknown> = {}) => <Text {...props}>{b(s)}</Text>
416
417 const para = (key: string, s: string, props: Record<string, unknown> = {}, w = width) => (
418 <Box key={key} flexDirection="column" alignItems="flex-end">
419 {wrap(s, w).map((l, i) => <Text key={`${key}:${i}`} {...props}>{b(l || ' ')}</Text>)}
420 </Box>
421 )
422
423 const heading = (key: string, s: string, level = 1) => (
424 <Box key={key} flexDirection="column" alignItems="flex-end" marginTop={level > 1 ? 1 : 0}>
425 <Text bold color={level === 1 ? tone.accent : undefined}>{b(s)}</Text>
426 </Box>
427 )
428
429 const muted = (key: string, s: string) => para(key, s, { color: tone.muted })
430
431 const row = (key: string, children: Node[], props: Record<string, unknown> = {}) => (
432 <Box key={key} flexDirection="row-reverse" flexWrap="wrap" gap={1} alignItems="center" {...props}>{children}</Box>
433 )
434
435 const col = (key: string, children: Node[], props: Record<string, unknown> = {}) => (
436 <Box key={key} flexDirection="column" alignItems="flex-end" {...props}>{children}</Box>
437 )
438
439 const action = (key: string, label: string, onPress: Press, props: Record<string, unknown> = {}) =>
440 <Button key={key} label={b(label)} variant="secondary" onPress={run(onPress)} {...props} />
441 const primary = (key: string, label: string, onPress: Press, props: Record<string, unknown> = {}) =>
442 <Button key={key} label={b(label)} variant="primary" onPress={run(onPress)} {...props} />
443 const quiet = (key: string, label: string, onPress: Press, props: Record<string, unknown> = {}) =>
444 <Button key={key} label={b(label)} dimColor onPress={run(onPress)} {...props} />
445
446 // A list row you press: the label as a plain button, a muted line under it.
447 // The button carries `key`, so the caller presses the key it passed.
448 const item = (key: string, label: string, onPress: Press, meta?: string) => (
449 <Box key={`${key}:item`} flexDirection="column" alignItems="flex-end">
450 <Button key={key} label={b(label)} plain onPress={run(onPress)} />
451 {meta ? para(`${key}:meta`, meta, { color: tone.muted }) : null}
452 </Box>
453 )
454
455 const chip = (key: string, label: string, t: Tone = 'accent') => (isTerminal
456 ? <Text key={key} color={tone[t]}>{b(`[${label}]`)}</Text>
457 : <Text key={key} color={tone[t]} backgroundColor={tone.fill} bold>{b(` ${label} `)}</Text>)
458
459 const chips = (key: string, items: SvgChip[]) => row(key, items.map((c, i) => chip(`${key}:${i}`, c.label, c.tone)))
460
461 const stat = (key: string, value: string | number, label: string, t: Tone = 'accent') => (isTerminal
462 ? <Text key={key}>{b(`${label}: ${value}`)}</Text>
463 : (
464 <Box key={key} flexDirection="column" alignItems="flex-end" paddingX={1}>
465 <Text bold color={tone[t]}>{String(value)}</Text>
466 <Text color={tone.muted}>{b(label)}</Text>
467 </Box>
468 ))
469
470 const card = (key: string, o: { title?: string; meta?: string; tone?: Tone; actions?: Node[] }, children: Node[] = []) => (
471 <Box key={key} flexDirection="column" alignItems="flex-end" borderStyle="round"
472 borderColor={o.tone ? tone[o.tone] : tone.line} hover={{ borderColor: tone.accent }} paddingX={1} marginBottom={1}>
473 {o.title ? <Text bold>{b(o.title)}</Text> : null}
474 {o.meta ? para(`${key}:meta`, o.meta, { color: tone.muted }, width - 4) : null}
475 {children}
476 {o.actions && o.actions.length ? row(`${key}:actions`, o.actions, { marginTop: 1 }) : null}
477 </Box>
478 )
479
480 const tabs = (key: string, items: { id: string; label: string }[], current: string, onPick: (id: string) => unknown) =>
481 row(key, items.map(t => (t.id === current
482 ? <Button key={`${key}:${t.id}`} label={b(t.label)} variant="primary" onPress={run(() => onPick(t.id))} />
483 : <Button key={`${key}:${t.id}`} label={b(t.label)} plain onPress={run(() => onPick(t.id))} />)), { marginBottom: 1 })
484
485 // Items with the marker on the right; depth indents from the right.
486 const list = (key: string, items: (string | { text: string; depth?: number })[], o: { ordered?: boolean } = {}) => (
487 <Box key={key} flexDirection="column" alignItems="flex-end">
488 {items.map((x, i) => {
489 const it = typeof x === 'string' ? { text: x, depth: 0 } : { text: x.text, depth: x.depth ?? 0 }
490 const marker = o.ordered ? `${i + 1}.` : '•'
491 return listRow(`${key}:${i}`, marker, it.text, it.depth)
492 })}
493 </Box>
494 )
495
496 const listRow = (key: string, marker: string, s: string, depth: number) => {
497 const w = Math.max(12, width - 3 - depth * 2)
498 const lines = wrap(s, w)
499 return (
500 <Box key={key} flexDirection="row-reverse" gap={1} paddingRight={depth * 2}>
501 <Text color={tone.muted}>{marker}</Text>
502 <Box flexDirection="column" alignItems="flex-end">
503 {lines.map((l, i) => <Text key={`${key}:${i}`}>{b(l || ' ')}</Text>)}
504 </Box>
505 </Box>
506 )
507 }
508
509 // Columns right to left; each cell wraps inside its share of the width.
510 const table = (key: string, headers: string[], rows: string[][], o: { widths?: number[] } = {}) => {
511 const n = Math.max(headers.length, ...rows.map(r => r.length), 1)
512 const widths = o.widths && o.widths.length === n ? o.widths : Array.from({ length: n }, () => 100 / n)
513 const cell = (rk: string, c: string, i: number, props: Record<string, unknown>) => (
514 <Box key={`${rk}:${i}`} width={`${Math.round(widths[i] ?? 0)}%`} flexDirection="column" alignItems="flex-end" paddingX={1}>
515 {wrap(c, Math.max(6, Math.floor(((width - 2) * (widths[i] ?? 0)) / 100) - 2)).map((l, j) => <Text key={`${rk}:${i}:${j}`} {...props}>{b(l || ' ')}</Text>)}
516 </Box>
517 )
518 return (
519 <Box key={key} flexDirection="column" width="100%" borderStyle="round" borderColor={tone.line} marginBottom={1}>
520 <Box key={`${key}:head`} flexDirection="row-reverse">
521 {Array.from({ length: n }, (_, i) => cell(`${key}:head`, headers[i] ?? '', i, { bold: true, color: tone.muted }))}
522 </Box>
523 {rows.map((r, ri) => (
524 <Box key={`${key}:r${ri}`} flexDirection="row-reverse">
525 {Array.from({ length: n }, (_, i) => cell(`${key}:r${ri}`, r[i] ?? '', i, {}))}
526 </Box>
527 ))}
528 </Box>
529 )
530 }
531
532 // Markdown drawn with the builders above, because the Markdown element cannot do right to left.
533 const md = (key: string, src: string) => (
534 <Box key={key} flexDirection="column" alignItems="flex-end">
535 {parseMd(src).map((blk, i) => {
536 const k = `${key}:${i}`
537 switch (blk.kind) {
538 case 'heading':
539 return heading(k, blk.text, blk.level)
540 case 'para':
541 return <Box key={k} marginBottom={1}>{para(`${k}:p`, blk.text)}</Box>
542 case 'item':
543 return listRow(k, blk.marker, blk.text, blk.depth)
544 case 'quote':
545 return card(k, { tone: 'line' }, [para(`${k}:q`, blk.text, {}, width - 4)])
546 case 'table':
547 return table(k, blk.headers, blk.rows)
548 case 'code':
549 return <Box key={k} marginBottom={1} borderStyle="round" borderColor={tone.line} paddingX={1}><Text>{blk.text}</Text></Box>
550 case 'rule':
551 return <Text key={k} color={tone.line}>{'─'.repeat(Math.min(40, width))}</Text>
552 }
553 })}
554 </Box>
555 )
556
557 // An SVG piece, or nothing on a surface without Svg.
558 const svg = (key: string, piece: { source: string; width: number; height: number }, alt: string) =>
559 (!isTerminal && 'Svg' in el ? <el.Svg key={key} source={piece.source} alt={alt} width={piece.width} height={piece.height} /> : null)
560
561 // Pixels for an SVG that spans the pane: an estimate from the columns.
562 const svgWidth = Math.max(280, Math.min(720, Math.round(cols * 7.2)))
563
564 return {
565 surface, isTerminal, cols, width, svgWidth, bidi: b,
566 text, para, heading, muted, row, col, action, primary, quiet, item, chip, chips, stat, card, tabs, list, table, md, svg,
567 }
568}
569types/index.d.ts 8 lines1export type RtlKitDemo = { section: string; pressed: string | null }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'rtl-kit': { demo: RtlKitDemo }
6 }
7}
8