SLOPSHOPPER

hebrew-rtl-ubuntu

Right-to-left Hebrew in the Claude Code terminal (tested on Ubuntu GNOME Terminal): Hebrew paragraphs (by majority of letters) are drawn RTL and right-aligned…

newrowscommand
v1.0.0MITupdated 2026-10-07divonr/hebrew-rtl-ubuntu
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hebrew-rtl-ubuntu
› 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 ⎿ hebrew-rtl-ubuntu: RTL off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

hebrew-rtl-ubuntu

A Claude Code mod that draws Hebrew right-to-left in the terminal.

  • Each paragraph (every Enter) is Hebrew or English by majority of letters, not by its first character.
  • Hebrew paragraphs are drawn in true RTL order and right-aligned: punctuation lands on the left, English words inside keep their place, brackets mirror, lists and headings sit on the right.
  • English paragraphs, code blocks and English tables are left exactly as Claude Code draws them.
  • Tables whose letters are mostly Hebrew are drawn mirrored, first column on the right.
  • Applies to Claude's replies and to your own messages. /rtl toggles it.

Install

At the prompt of a Claude Code terminal session:

/plugin install hebrew-rtl-ubuntu --marketplace divonr/hebrew-rtl-ubuntu

Answer y to add the marketplace, then press Enter for the user scope. It's active right away.

Tested on

Ubuntu 24.04, GNOME Terminal (VTE 0.76). That is the only setup it has been tested on.

Claude Code strips bidi control characters, so the mod can't simply mark a line as RTL. Instead it arranges each Hebrew line's characters so that the terminal's own bidi reordering produces the RTL picture. In principle it should work in any terminal that already draws Hebrew letters in the right order on its own (without reversing words), but that hasn't been verified anywhere else. In a terminal that doesn't reorder Hebrew at all, Hebrew letters will appear reversed. If you try it elsewhere, reports are welcome in the issues.

Limits

  • The box you type into isn't affected; your message switches to RTL once sent.
  • A number at the very start of a Hebrew line (3 תפוחים) lands on the left.
  • Numbered lists in Hebrew show as ① ② ③.

License

MIT

Source 3 files
hooks/register.tsx 123 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { BOLD, CODE, ITALIC, LINK, STRIKE, hasHebrew, rtlRows, rtlTable, segment, spans } from './bidi'
5import type { Glyph } from './bidi'
6
7const isEnabled = atom({ plugin: 'hebrew-rtl-ubuntu', key: 'isEnabled' } as const, true)
8
9const GUTTER = 2
10
11function wants(surface: string, text: string): boolean {
12  return (
13    surface === 'terminal' &&
14    hasHebrew(text) &&
15    segment(text).some(s => s.kind === 'rtl' || (s.kind === 'table' && s.rtl))
16  )
17}
18
19// One terminal row of glyphs already arranged for the terminal's bidi pass,
20// drawn as styled spans side by side.
21function glyphRow(Box: any, Text: any, glyphs: Glyph[]) {
22  return (
23    <Box flexDirection="row" flexShrink={0}>
24      {spans(glyphs).map(span => (
25        <Text
26          bold={(span.style & BOLD) !== 0}
27          italic={(span.style & ITALIC) !== 0}
28          underline={(span.style & LINK) !== 0}
29          strikethrough={(span.style & STRIKE) !== 0}
30          color={span.style & CODE ? 'permission' : undefined}
31        >
32          {span.text}
33        </Text>
34      ))}
35    </Box>
36  )
37}
38
39// Draws a message body. `markdown` (a reply) draws LTR runs and LTR tables
40// through the engine's own Markdown; a prompt draws its text as typed.
41// RTL rows are right-aligned one per line; an RTL table is box-drawn.
42function body(Box: any, Text: any, Markdown: any, text: string, width: number, markdown: boolean) {
43  const line = (text: string) => (markdown ? <Markdown text={text} /> : <Text>{text}</Text>)
44
45  return (
46    <Box flexDirection="column" width={width}>
47      {segment(text).map(s => (
48        <Box flexDirection="column" marginTop={s.gap ? 1 : 0}>
49          {s.kind === 'rtl' &&
50            s.lines.flatMap(source =>
51              rtlRows(source, width, markdown).map(row => (
52                <Box justifyContent="flex-end" marginRight={row.indent} minHeight={1}>
53                  {row.glyphs.length > 0 && glyphRow(Box, Text, row.glyphs)}
54                </Box>
55              )),
56            )}
57          {s.kind === 'ltr' && line(s.text)}
58          {s.kind === 'table' &&
59            (s.rtl ? (
60              <Box flexDirection="column" alignItems="flex-end">
61                {rtlTable(s.rows).map(row => glyphRow(Box, Text, row))}
62              </Box>
63            ) : (
64              line(s.source)
65            ))}
66        </Box>
67      ))}
68    </Box>
69  )
70}
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    await $.command.register({
75      name: 'rtl',
76      description: 'Toggle right-to-left layout for Hebrew paragraphs',
77    })
78
79    return next(e)
80  })
81
82  on('command.run', { command: 'rtl' }, async $ => {
83    const now = await update($, isEnabled, was => !was)
84
85    return { text: now ? 'RTL on: Hebrew paragraphs are right-aligned.' : 'RTL off.' }
86  })
87
88  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
89    if (e.props.isSummary || !wants(e.surface, e.props.text) || !(await read($, isEnabled))) {
90      return next(e)
91    }
92    const { Box, Text, Markdown } = $.ui.resolve(e)
93    const width = (e.viewport?.columns ?? 80) - GUTTER - 1
94
95    return (
96      <Box flexDirection="row">
97        <Box width={GUTTER} flexShrink={0}>
98          <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
99        </Box>
100        {body(Box, Text, Markdown, e.props.text, width, true)}
101      </Box>
102    )
103  })
104
105  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
106    const isPrompt = e.props.origin.kind === 'composer' || e.props.origin.kind === 'bridge'
107    if (!isPrompt || !wants(e.surface, e.props.text) || !(await read($, isEnabled))) {
108      return next(e)
109    }
110    const { Box, Text, Markdown } = $.ui.resolve(e)
111    const width = (e.viewport?.columns ?? 80) - GUTTER - 1
112
113    return (
114      <Box flexDirection="row" marginTop={1} backgroundColor="userMessageBackground">
115        <Box width={GUTTER} flexShrink={0}>
116          <Text dimColor>❯</Text>
117        </Box>
118        {body(Box, Text, Markdown, e.props.text, width, false)}
119      </Box>
120    )
121  })
122}
123
hooks/bidi.ts 446 lines
1// Pure text logic: deciding each line's direction and laying out RTL rows.
2//
3// The terminal (VTE) reorders every row with the Unicode bidi algorithm on an
4// LTR base, and Claude Code strips every bidi control character (RLM, RLE,
5// isolates). So an RTL row is drawn by arrangement alone: compute how the row
6// looks in a real RTL paragraph (`visual`, base RTL), then find the logical
7// string that the terminal's own LTR pass turns into exactly that picture
8// (`forTerminal`).
9
10const HEBREW = /[א-תװ-ײיִ-ﭏ]/g
11const LATIN = /[A-Za-zÀ-ɏ]/g
12const FENCE = /^\s*(```|~~~)/
13const TABLE_RULE = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
14
15// ---------------------------------------------------------------- direction
16
17/** RTL when Hebrew letters outnumber Latin ones; code, URLs and link targets don't vote. */
18export const isRtl = (text: string): boolean => {
19  const voting = text
20    .replace(/`[^`]*`/g, '')
21    .replace(/\]\([^)]*\)/g, ']')
22    .replace(/https?:\/\/\S+/g, '')
23  const hebrew = voting.match(HEBREW)?.length ?? 0
24  const latin = voting.match(LATIN)?.length ?? 0
25
26  return hebrew > latin
27}
28
29export const hasHebrew = (text: string): boolean => /[א-ת]/.test(text)
30
31// ---------------------------------------------------------------- the bidi algorithm
32
33type BidiClass = 'L' | 'R' | 'EN' | 'ES' | 'ET' | 'CS' | 'WS' | 'ON'
34
35/** A character with what follows it on the same cell (niqqud, accents). */
36export type Glyph = { ch: string; style: number }
37
38const R_CHAR = /[־׀׃׆א-תׯ-״יִײַ-ﭏ؀-ۿ]/u
39const MARK = /\p{M}/u
40const MIRROR: Record<string, string> = {
41  '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<',
42  '«': '»', '»': '«', '‹': '›', '›': '‹',
43}
44
45const classOf = (ch: string): BidiClass => {
46  const base = [...ch][0] ?? ' '
47  if (R_CHAR.test(base)) return 'R'
48  if (/[0-9]/.test(base)) return 'EN'
49  if (/[+\-]/.test(base)) return 'ES'
50  if (/[#$%°¢£¤¥₪€‰]/.test(base)) return 'ET'
51  if (/[,.:/ ]/.test(base)) return 'CS'
52  if (/\s/.test(base)) return 'WS'
53  if (/[\p{L}\p{M}]/u.test(base)) return 'L'
54
55  return 'ON'
56}
57
58/** Splits text into cells: a base character with its combining marks. */
59export const clusters = (text: string): string[] => {
60  const out: string[] = []
61  for (const ch of text) {
62    if (MARK.test(ch) && out.length > 0) out[out.length - 1] += ch
63    else out.push(ch)
64  }
65
66  return out
67}
68
69/** Embedding levels of one paragraph (rules W1–W7, N1–N2, I1–I2, L1). */
70const levels = (types: BidiClass[], base: 0 | 1): number[] => {
71  const t = [...types]
72  const n = t.length
73  const baseDir: BidiClass = base ? 'R' : 'L'
74  const strongBefore = (k: number): BidiClass => {
75    for (let j = k - 1; j >= 0; j--) if (t[j] === 'L' || t[j] === 'R') return t[j]!
76    return baseDir
77  }
78  // W4: a single separator between two numbers joins them.
79  for (let k = 1; k < n - 1; k++) {
80    if (t[k - 1] === 'EN' && t[k + 1] === 'EN' && (t[k] === 'ES' || t[k] === 'CS')) t[k] = 'EN'
81  }
82  // W5: currency and percent signs next to a number belong to it.
83  for (let k = 0; k < n; k++) {
84    if (t[k] !== 'ET') continue
85    let end = k
86    while (end < n && t[end] === 'ET') end++
87    if (t[k - 1] === 'EN' || t[end] === 'EN') for (let j = k; j < end; j++) t[j] = 'EN'
88    k = end - 1
89  }
90  // W6: what's left of the separators is neutral.
91  for (let k = 0; k < n; k++) if (t[k] === 'ES' || t[k] === 'ET' || t[k] === 'CS') t[k] = 'ON'
92  // W7: a number after Latin text reads as Latin.
93  for (let k = 0; k < n; k++) if (t[k] === 'EN' && strongBefore(k) === 'L') t[k] = 'L'
94  // N1, N2: neutrals take their neighbours' direction, or the paragraph's.
95  const dir = (c: BidiClass | undefined): 'L' | 'R' => (c === 'L' ? 'L' : 'R')
96  for (let k = 0; k < n; k++) {
97    if (t[k] !== 'ON' && t[k] !== 'WS') continue
98    let end = k
99    while (end < n && (t[end] === 'ON' || t[end] === 'WS')) end++
100    const before = k === 0 ? baseDir : dir(t[k - 1])
101    const after = end === n ? baseDir : dir(t[end])
102    const resolved = before === after ? before : baseDir
103    for (let j = k; j < end; j++) t[j] = resolved
104    k = end - 1
105  }
106  // I1, I2.
107  const out = t.map(c =>
108    base === 0 ? (c === 'R' ? 1 : c === 'EN' ? 2 : 0) : c === 'L' || c === 'EN' ? 2 : 1,
109  )
110  // L1: trailing whitespace sits at the paragraph's level.
111  for (let k = n - 1; k >= 0 && types[k] === 'WS'; k--) out[k] = base
112
113  return out
114}
115
116/** How a row of glyphs is drawn left to right in a paragraph of direction `base`. */
117export const visual = (row: Glyph[], base: 0 | 1): Glyph[] => {
118  const lv = levels(row.map(g => classOf(g.ch)), base)
119  const order = row.map((_, k) => k)
120  const top = Math.max(0, ...lv)
121  const lowestOdd = Math.min(...lv.filter(l => l % 2 === 1), top + 1)
122  for (let level = top; level >= lowestOdd; level--) {
123    for (let k = 0; k < order.length; k++) {
124      if ((lv[order[k]!] ?? 0) < level) continue
125      let end = k
126      while (end < order.length && (lv[order[end]!] ?? 0) >= level) end++
127      const run = order.slice(k, end).reverse()
128      order.splice(k, run.length, ...run)
129      k = end
130    }
131  }
132
133  return order.map(k => {
134    const g = row[k]!
135    const mirrored = (lv[k] ?? 0) % 2 === 1 ? MIRROR[g.ch] : undefined
136    return mirrored ? { ...g, ch: mirrored } : g
137  })
138}
139
140const NUMBER = /[#$%°¢£¤¥₪€‰]*[0-9]+(?:[.,:/][0-9]+)*[#$%°¢£¤¥₪€‰]*/g
141
142/**
143 * The logical row the terminal's LTR bidi pass draws as `picture`: each span
144 * of the picture that runs from a Hebrew letter to a Hebrew letter with no
145 * Latin letter inside is handed over reversed (numbers inside kept as they
146 * read, brackets swapped), since the terminal will reverse it back.
147 */
148export const forTerminal = (picture: Glyph[]): Glyph[] => {
149  const types = picture.map(g => classOf(g.ch))
150  const out = [...picture]
151  for (let k = 0; k < out.length; k++) {
152    if (types[k] !== 'R') continue
153    let end = k
154    for (let j = k + 1; j < out.length && types[j] !== 'L'; j++) if (types[j] === 'R') end = j
155    const span = picture.slice(k, end + 1).reverse()
156    const text = span.map(g => (g.ch.length === 1 ? g.ch : 'x')).join('')
157    for (const m of text.matchAll(NUMBER)) {
158      const at = m.index ?? 0
159      const number = span.slice(at, at + m[0].length).reverse()
160      span.splice(at, number.length, ...number)
161    }
162    out.splice(k, span.length, ...span.map(g => ({ ...g, ch: MIRROR[g.ch] ?? g.ch })))
163    k = end
164  }
165
166  return out
167}
168
169/** One RTL row, ready for the terminal. */
170export const rtlRow = (row: Glyph[]): Glyph[] => forTerminal(visual(row, 1))
171
172// ---------------------------------------------------------------- inline markdown
173
174export const BOLD = 1
175export const ITALIC = 2
176export const CODE = 4
177export const LINK = 8
178export const STRIKE = 16
179
180/** Inline markdown as styled glyphs: bold, italic, code, links, strikethrough. */
181export const inline = (text: string): Glyph[] => {
182  const out: Glyph[] = []
183  let style = 0
184  const chars = [...text]
185  for (let k = 0; k < chars.length; k++) {
186    const ch = chars[k]!
187    const next = chars[k + 1]
188    if (ch === '\\' && next !== undefined && /[\\`*_[\]()#+\-.!~|]/.test(next)) {
189      out.push({ ch: next, style })
190      k++
191      continue
192    }
193    if (ch === '`') {
194      const close = chars.indexOf('`', k + 1)
195      if (close > k) {
196        for (const c of chars.slice(k + 1, close)) out.push({ ch: c, style: style | CODE })
197        k = close
198        continue
199      }
200    }
201    if ((ch === '*' || ch === '_') && next === ch) {
202      style ^= BOLD
203      k++
204      continue
205    }
206    if (ch === '~' && next === '~') {
207      style ^= STRIKE
208      k++
209      continue
210    }
211    if (ch === '*' && next !== ' ') {
212      if ((style & ITALIC) || chars.indexOf('*', k + 1) > k) {
213        style ^= ITALIC
214        continue
215      }
216    }
217    if (ch === '[') {
218      const rest = chars.slice(k).join('')
219      const link = rest.match(/^\[([^\]]*)\]\(([^)]*)\)/)
220      if (link) {
221        for (const c of link[1]!) out.push({ ch: c, style: style | LINK })
222        k += [...link[0]].length - 1
223        continue
224      }
225    }
226    out.push({ ch, style })
227  }
228
229  // Marks join the cell before them.
230  const merged: Glyph[] = []
231  for (const g of out) {
232    const last = merged[merged.length - 1]
233    if (MARK.test(g.ch) && last) last.ch += g.ch
234    else merged.push({ ...g })
235  }
236
237  return merged
238}
239
240export const plainGlyphs = (text: string): Glyph[] => clusters(text).map(ch => ({ ch, style: 0 }))
241
242// ---------------------------------------------------------------- layout
243
244export type Segment =
245  | { kind: 'ltr'; text: string; gap: boolean }
246  | { kind: 'rtl'; lines: string[]; gap: boolean }
247  | { kind: 'table'; rtl: boolean; source: string; rows: string[][]; gap: boolean }
248
249const parseRow = (line: string): string[] =>
250  line
251    .trim()
252    .replace(/^\|/, '')
253    .replace(/\|$/, '')
254    .split(/(?<!\\)\|/)
255    .map(cell => cell.trim().replace(/\\\|/g, '|'))
256
257/**
258 * Splits text into runs. Every line (one Enter) is its own paragraph and
259 * votes alone; a code fence is always LTR; a table votes as a whole. `gap`
260 * marks a run that blank lines separate from the one before.
261 */
262export const segment = (text: string): Segment[] => {
263  const lines = text.split('\n')
264  const at = (k: number): string => lines[k] ?? ''
265  const out: Segment[] = []
266  let gap = false
267
268  const add = (kind: 'ltr' | 'rtl', chunk: string) => {
269    const last = out[out.length - 1]
270    if (last?.kind === 'rtl' && kind === 'rtl') {
271      if (gap) last.lines.push('')
272      last.lines.push(chunk)
273    } else if (last?.kind === 'ltr' && kind === 'ltr') {
274      last.text += (gap ? '\n\n' : '\n') + chunk
275    } else if (kind === 'rtl') {
276      out.push({ kind, lines: [chunk], gap: gap && out.length > 0 })
277    } else {
278      out.push({ kind, text: chunk, gap: gap && out.length > 0 })
279    }
280    gap = false
281  }
282
283  let i = 0
284  while (i < lines.length) {
285    const line = at(i)
286    if (FENCE.test(line)) {
287      const fence = line.trim().slice(0, 3)
288      const block = [line]
289      i++
290      while (i < lines.length) {
291        block.push(at(i))
292        i++
293        if (at(i - 1).trim().startsWith(fence)) break
294      }
295      add('ltr', block.join('\n'))
296      continue
297    }
298    if (line.trim() === '') {
299      gap = true
300      i++
301      continue
302    }
303    if (line.trim().startsWith('|') && TABLE_RULE.test(at(i + 1))) {
304      const block: string[] = []
305      while (i < lines.length && at(i).trim().startsWith('|')) {
306        block.push(at(i))
307        i++
308      }
309      const rows = block.filter((_, k) => k !== 1).map(parseRow)
310      out.push({
311        kind: 'table',
312        rtl: isRtl(rows.flat().join(' ')),
313        source: block.join('\n'),
314        rows,
315        gap: gap && out.length > 0,
316      })
317      gap = false
318      continue
319    }
320    add(isRtl(line) ? 'rtl' : 'ltr', line)
321    i++
322  }
323
324  return out
325}
326
327/** Greedy word wrap of glyphs to `width` cells; spaces at a break are dropped. */
328export const wrap = (glyphs: Glyph[], width: number): Glyph[][] => {
329  const words: Glyph[][] = []
330  let word: Glyph[] = []
331  for (const g of glyphs) {
332    if (g.ch === ' ') {
333      if (word.length) words.push(word)
334      word = []
335    } else word.push(g)
336  }
337  if (word.length) words.push(word)
338
339  const rows: Glyph[][] = []
340  let row: Glyph[] = []
341  for (const w of words) {
342    if (row.length === 0) row = [...w]
343    else if (row.length + 1 + w.length <= width) {
344      const space = { ch: ' ', style: (row[row.length - 1]!.style & w[0]!.style) }
345      row.push(space, ...w)
346    } else {
347      rows.push(row)
348      row = [...w]
349    }
350  }
351  if (row.length || rows.length === 0) rows.push(row)
352
353  return rows
354}
355
356export type RtlRow = { glyphs: Glyph[]; indent: number }
357
358/**
359 * One source line of RTL text as the rows it draws, each ready for the
360 * terminal: markdown block syntax (lists, headings, quotes) turned into
361 * glyphs that sit on the right, indentation moved to the right.
362 * `markdown` false keeps the line as typed.
363 */
364export const rtlRows = (line: string, width: number, markdown = true): RtlRow[] => {
365  if (line.trim() === '') return [{ glyphs: [], indent: 0 }]
366  const indent = line.match(/^\s*/)![0].replace(/\t/g, '  ').length
367  let body = line.trim()
368  let glyphs: Glyph[]
369  if (markdown) {
370    let style = 0
371    const heading = body.match(/^#{1,6}\s+(.*?)\s*#*$/)
372    if (heading) {
373      body = heading[1]!
374      style = BOLD
375    }
376    body = body
377      .replace(/^>\s?/, '│ ')
378      .replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, (_, box?: string) =>
379        box ? (box.trim() === '[ ]' ? '☐ ' : '☑ ') : '• ',
380      )
381      // A list number would be pulled to the wrong side by the terminal; a
382      // circled number is a symbol, and stays where it's put.
383      .replace(/^(\d+)[.)]\s+/, (m, d: string) => {
384        const n = Number(d)
385        return n >= 1 && n <= 20 ? String.fromCodePoint(0x2460 + n - 1) + ' ' : m
386      })
387    glyphs = inline(body).map(g => ({ ...g, style: g.style | style }))
388  } else {
389    glyphs = plainGlyphs(body)
390  }
391
392  return wrap(glyphs, Math.max(10, width - indent)).map(row => ({ glyphs: rtlRow(row), indent }))
393}
394
395/** Consecutive glyphs of one style, as the spans a row draws. */
396export const spans = (glyphs: Glyph[]): { text: string; style: number }[] => {
397  const out: { text: string; style: number }[] = []
398  for (const g of glyphs) {
399    const last = out[out.length - 1]
400    if (last && last.style === g.style) last.text += g.ch
401    else out.push({ text: g.ch, style: g.style })
402  }
403
404  return out
405}
406
407/**
408 * An RTL table as rows ready for the terminal: the picture is built as it
409 * should look (first column on the right, cells right-aligned, each cell's
410 * text in RTL order), then arranged for the terminal like any RTL row.
411 */
412export const rtlTable = (rows: string[][]): Glyph[][] => {
413  const columns = Math.max(...rows.map(r => r.length))
414  const grid = rows.map(r =>
415    Array.from({ length: columns }, (_, c) => visual(inline(r[c] ?? ''), 1)),
416  )
417  const widths = Array.from({ length: columns }, (_, c) =>
418    Math.max(1, ...grid.map(r => r[c]?.length ?? 0)),
419  )
420  const g = (text: string, style = 0): Glyph[] => plainGlyphs(text).map(x => ({ ...x, style }))
421  const mirrored = [...widths].reverse()
422  const rule = (left: string, mid: string, right: string) =>
423    g(left + mirrored.map(w => '─'.repeat(w + 2)).join(mid) + right)
424  const row = (cells: Glyph[][], style: number): Glyph[] => {
425    const picture: Glyph[] = g('│')
426    cells
427      .map((cell, c) => ({ cell, width: widths[c] ?? 0 }))
428      .reverse()
429      .forEach(({ cell, width }) => {
430        picture.push(...g(' '.repeat(width - cell.length + 1)))
431        picture.push(...cell.map(x => ({ ...x, style: x.style | style })))
432        picture.push(...g(' │'))
433      })
434    return forTerminal(picture)
435  }
436  const [header, ...body] = grid
437
438  return [
439    rule('┌', '┬', '┐'),
440    row(header ?? [], BOLD),
441    rule('├', '┼', '┤'),
442    ...body.map(r => row(r, 0)),
443    rule('└', '┴', '┘'),
444  ]
445}
446
types/index.d.ts 8 lines
1export type RtlEnabled = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'hebrew-rtl-ubuntu': { isEnabled: RtlEnabled }
6  }
7}
8