SLOPSHOPPER

rtl-kit

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…

newpanecommand
v0.1.1MITupdated 2026-10-05barmoshe/bar-mods/rtl-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rtl-kit
│ ┃ rtl-kit ✕ › fix the failing auth test and add an audit log call │ ┃ לגרס לנימרט SVG Markdown םיביכר [ טסקט ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ תוברועמ תורוש ⎿ Read 6 lines │ ┃ 10:00–12:00 ,תושקב Claude: 3-מ שקבל ⏺ Update(src/auth.ts) │ ┃ .ماقرأ 3 و Claude عم ةيبرعلاب رطس اذه :ابحرم ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ הכורא הקספ ⎿ 3 pass, 1 fail │ ┃ ליחתהל הכירצ הרוש לכ :תורוש המכ תרבועש │ ┃ תירבעב הכורא הקספ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ומכ ךיראת ,Claude ומכ תילגנאב הלימ הב שישכ │ ┃ םג ,ןימי דצב ✻ Worked for 42s · done 4:20 PM │ ┃ היינשה הרושה ,הכרעה ילב .42 ומכ רפסמ וא │ ┃ ,7.10.2026 › /rtl-gallery │ ┃ .לאמשמ הליחתמ ⎿ rtl-kit: rtl-kit is open. │ ┃ │ ┃ םירותפכ │ ┃ [ לוטיב ] [ Claude-מ שקבל ] [ תישאר הלועפ ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rtl-kit
לגרס לנימרט SVG Markdown םיביכר [ טסקט ] תוברועמ תורוש 10:00–12:00 ,תושקב Claude: 3-מ שקבל .ماقرأ 3 و Claude عم ةيبرعلاب رطس اذه :ابحرم הכורא הקספ ליחתהל הכירצ הרוש לכ :תורוש המכ תרבועש תירבעב הכורא הקספ ומכ ךיראת ,Claude ומכ תילגנאב הלימ הב שישכ םג ,ןימי דצב היינשה הרושה ,הכרעה ילב .42 ומכ רפסמ וא ,7.10.2026 .לאמשמ הליחתמ םירותפכ [ לוטיב ] [ Claude-מ שקבל ] [ תישאר הלועפ ]
README

rtl-kit

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:

  • Order: every string goes out wrapped in a right-to-left embedding, so לבקש מ-Claude: 3 בקשות, 10:00–12:00 reads right, in text and in button labels.
  • Alignment: long text is wrapped by the kit, one line at a time, each aligned right.
  • Layout: rows run right to left, columns align right, list markers and table columns start on the right.
  • Markdown: k.md() draws headings, paragraphs, nested lists, quotes and tables with the pieces above.
  • Design: cards with hover, tabs, chips, stats, one primary button per screen, and colours from the app's theme keys, so light and dark both work.
  • SVG: a header with chips and a day timeline (lanes against hours, right to left), drawn where the surface has Svg.
  • Terminal: the visual order is computed (Latin words and numbers stay whole, brackets mirror), so Hebrew reads right there too.

Use it

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.

What it does outside the pane

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.

Footprint

From claude plugin validate:

  • hooks: session.start, command.run{command=rtl-gallery}, ui.render{component=Pane, requestId=rtl-kit}
  • calls: $.command.register, $.config.list, $.state.get, $.state.set, $.ui.open, $.ui.resolve

Limits

  • Wrapping is by character count, and fonts are proportional: a line can still wrap in the app, and that one wrapped line starts at the left. The default width (90% of the pane's columns) kept every line whole on the desktop; charsPerColumn changes it.
  • The terminal reordering is a simplified bidi: no combining marks (niqqud), no nested embeddings.
  • Claude Code's theme setting describes the terminal, not the desktop app, so SVG on the desktop follows the system's colour scheme.

Surfaces

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.

Source 3 files
hooks/register.tsx 163 lines
1// 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}
163
hooks/rtl.tsx 569 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
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}
569
types/index.d.ts 8 lines
1export type RtlKitDemo = { section: string; pressed: string | null }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'rtl-kit': { demo: RtlKitDemo }
6  }
7}
8