SLOPSHOPPER

rtl-text

Shapes and reorders Persian, Arabic and Hebrew text in the transcript through fribidi so the terminal draws it joined, right-to-left and right-aligned.

newrowsprocess
v0.3.1MITupdated 2026-09-18aliir74/claude-code-rtl
A shopper browsing a rack in a slop shop
README

rtl-text

A Claude Code mod that makes Persian, Arabic and Hebrew readable in the terminal transcript.

Most terminals have no UAX #9 bidi and no Arabic shaping, so Persian arrives reversed and with its letters unjoined. This mod hooks the transcript's render events, runs each line through fribidi, and draws the result: letters joined, order right-to-left, RTL paragraphs flush right.

The same Persian exchange in Ghostty, before and after the mod

Claude Code mods are early access and off by default. This one does nothing at all until you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 on a recent enough Claude Code. See Requirements.

It shapes what Claude Code prints. It cannot fix what you type into the prompt box; see Limits.

Requirements

1. fribidi

brew install fribidi        # macOS
apt install fribidi         # Debian/Ubuntu

2. A Claude Code new enough to carry the function-hooks runtime.

claude --version

Mods are an early-access feature. They are not in the public changelog and not in the official docs, so there is no published "available from" version to point at. What is known: 2.1.260 is the earliest build reported to carry the runtime, and this mod is tested on 2.1.271 through 2.1.273. If you are on something older and the mod does nothing, update before debugging anything else.

Because the feature is early access, the plugin API can change between releases without notice, so a Claude Code update may break this mod until it is rebuilt. That warning is Anthropic's own, from the generated type declarations.

3. Function hooks switched on. The feature is gated behind an environment variable even on a build that has it. Without it the plugin installs, loads and silently does nothing, which is the single most common reason this mod appears not to work.

The durable way is ~/.claude/settings.json, which applies to every session however you start it:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Or export it in your shell (~/.zshrc, ~/.bashrc) if you only ever launch Claude Code from a terminal:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

4. A monospace font covering the Arabic Presentation Forms block (U+FB50-U+FEFF), which is what this mod emits. Monospace matters: a proportional Persian face forced into a terminal's cell grid pulls the letters of a word apart, where a monospace face has its joined forms drawn to meet at the cell edges.

Vazir Code works (the "Vazir Code Hack" variant pairs it with Hack for the Latin glyphs). Note the project is discontinued, though the released fonts are fine. It has one gap that matters: no U+FEF5-U+FEFC, the eight lam-alef ligature forms (the لا in سلام). Fill just those from Vazirmatn, which you will need installed as well. In Ghostty:

font-family = "JetBrains Mono"
font-family = "Vazir Code Hack"
font-codepoint-map = U+FEF5-U+FEFC=Vazirmatn

Do not add a proportional face like Vazirmatn as a plain font-family fallback. It wins the Latin glyphs too and spoils your English text.

Terminal support

The mod itself is terminal-agnostic: it asks Claude Code for the terminal surface and nothing in it knows which terminal you run. What decides whether it helps or hurts is whether your terminal does its own bidi.

This mod emits text already reordered into visual order and already converted to presentation forms. Those characters still carry a strong RTL bidi class, so a terminal that runs its own UAX #9 pass will reorder them a second time and put you back where you started.

TerminalUse this mod?
GhosttyYes — testedNo bidi shipped; #1442 open
kittyExpected yes#2109 open since 2019
AlacrittyExpected yes#663 open since 2017
footExpected yes#756, declined by the maintainer
Windows TerminalExpected yes#538 open since 2019
VS Code terminal (xterm.js)Expected yesvscode#271615
iTerm2Only with its own RTL off3.6+ has experimental RTL under Settings → General → Experimental; off by default
WezTermOnly with bidi_enabled = falseThat is the default
macOS Terminal.appNoNative bidi via CoreText; would double-reverse
GNOME Terminal / VTENoBidi since VTE 0.58
KonsoleNoBug 403729 resolved fixed
mltermNoBidi when built --enable-fribidi, as most packages are

Only the Ghostty row is tested. Everything else is read off each project's own issue tracker, so treat it as a strong prior rather than a promise. If you try one, a PR correcting the row is welcome.

If your terminal is in the bottom group, you do not need this mod. Its rendering is already better than what this mod can offer, since it works on logical text and can handle the composer too.

Install

claude plugin marketplace add aliir74/claude-code-rtl
claude plugin install rtl-text@claude-code-rtl

Or run it straight from a clone, without installing:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/claude-code-rtl

Installing writes the enabledPlugins entry itself, at user scope, so it is on in every project. Later updates:

claude plugin update rtl-text

Remember requirement 3: without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 the plugin installs successfully and then does nothing.

What it covers

ComponentShaped
AssistantMessageyes, as markdown
UserMessageyes, as markdown, handed back to the engine so it keeps its background band
CommandOutputyes, as plain text
ToolResult (Bash only)yes, as plain text

Limits

The composer is not covered. Typing Persian into the prompt box is still broken, and no mod can fix it: the input is not a RenderComponent, and ui.input fires only for Input elements a render hook itself drew, never for Claude Code's own composer. Only your terminal gaining real bidi fixes that. For Ghostty that is PR #14142, not merged as of September 2026.

Copying shaped text gives you presentation forms. What is on screen is what gets yanked, so text copied out of the transcript is in visual order and will not paste cleanly back into a logical-order editor.

Two things it deliberately leaves alone. A code block, an inline ` code span and a URL are passed through untouched, so nothing reorders your commands. A Bash result whose output was too large and got persisted to a file is left to the engine, because redrawing stdout` would present a truncated view as if it were the whole thing.

Options

Set them in the plugin's config. Every one is optional and clamped in code, so a bad value degrades rather than failing the load.

OptionDefaultMeaning
alignmentautoauto right-aligns only paragraphs whose own base direction is RTL; left shapes without padding; right always right-aligns
fribidiPathfribidiPath to the binary
margin4Cells held back from viewport.columns before wrapping. Keep it at 1 or more: it is the slack that stops wrap: 'truncate-end' clipping the start of a right-aligned line if the cell measure is ever off by one
cacheSize256Shaped messages kept in the LRU
timeoutMs2000How long a fribidi call may take before the row falls back to the engine's own drawing
replyBullet⏺Marker on the opening line of a reply. Drawing our own tree replaces the engine's whole row, marker included, so the mod redraws it, on the right edge for RTL where the sentence starts; an empty string leaves it off

Troubleshooting

Nothing changes at all. The mod is failing silently by design: every hook falls back to the engine's own row rather than breaking your transcript. Work down this list.

fribidi --version                              # is the binary there?
grep FUNCTION_HOOKS ~/.claude/settings.json    # is the gate set?
claude plugin list                             # is rtl-text installed and enabled?

If you exported the variable in your shell instead of putting it in settings.json, check it with echo $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS. A settings.json entry will not show up there: it is set inside the Claude Code process, not in your shell.

If fribidi is installed somewhere unusual, set fribidiPath to its absolute path.

A no runnable fribidi after 3 tries line in the transcript. The mod probes for the binary on the first render and prints why each candidate failed. The message names the real reason, which is usually a path problem.

Letters are joined but gappy. That is the font, not the mod. See requirement 4 above: you are almost certainly rendering with a proportional face.

Persian text is reversed. Your terminal probably has its own bidi, and it is undoing the mod's work. Check the terminal support table.

How it works

text -> hasRtl? -> split blocks -> split markdown prefix -> protect code/URLs
     -> WRAP in logical order -> one fribidi call per direction -> restore -> pad -> Text rows

The ordering is the whole design. Wrapping happens in logical order against the viewport width, and only the finished lines are shaped. Shaping first and wrapping the visual result puts the paragraph's opening words on the last line, which is the bug harness/transform-real.check.ts exists to catch.

Two other decisions worth knowing:

  • fribidi never breaks lines for us. Its own --width breaking is not word-aware and splits words mid-token, so every call passes --nobreak and the wrapping is ours.
  • Padding is ours too. fribidi's --width padding is display-cell accurate, but it cannot express a per-line auto alignment, so cellWidth does it. That makes cellWidth load-bearing, which is why the harness checks it against fribidi's own padding as an independent oracle.

One fribidi subprocess handles a whole message per base direction, never one per line, and the result is cached by (markdown, width, text) because render hooks re-run on every redraw and resize.

Development

.claude-plugin/plugin.json   manifest and userConfig
.claude-plugin/marketplace.json
hooks/                       the mod; no npm dependencies, no Node imports
  register.ts                the four ui.render hooks and the engine adapter
  transform.ts               the pipeline
  segment.ts                 blocks, markdown prefixes, inline protection, tables
  wrap.ts  cell-width.ts  align.ts  lru-cache.ts  rtl-detect.ts  fribidi-args.ts
  render-tree.ts             lines -> the surface's Box/Text/Code constructors
tests/                       run by `claude plugin test`
harness/*.check.ts           run by tsx; the only tests that touch the real binary
.claude/types/               generated by /plugin-types, do not hand-edit
docs/research/               measured findings this mod was built from

Four checks, because none covers another:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .     # hooks + pure logic, fribidi mocked
npx --yes tsx --test harness/*.check.ts                      # the real fribidi binary
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate .  # structure: hooks, matchers, $ calls
npx --yes -p typescript@latest tsc -p tsconfig.json           # types

The hooks environment has no fs, network or process, so the real-binary tests cannot live under claude plugin test. They are named *.check.ts rather than *.test.ts because that runner globs the whole plugin directory and would refuse a module importing node:child_process.

Notes for anyone editing this

  • Elements are never object literals. They come from $.ui.resolve(e). A { type: 'Box' } literal typechecks fine and is then refused at runtime, and the engine quietly redraws its own row, which looks exactly like the mod not being installed.
  • $ may only be passed to a function declared at the top of the file. claude plugin validate enforces this so it can report which $ calls a plugin makes. That is why the helpers in register.ts are top-level functions taking a Context rather than closures.
  • No raw NUL or private-use characters in source. Use String.fromCharCode. A literal escape can land as a real control byte, which turns the file binary and makes grep silently miss it.
  • userConfig entries need a title. The manifest schema rejects them otherwise.
  • Regenerate the type contract after a Claude Code update: claude -p '/plugin-types'.
  • Bump the version in BOTH plugin.json and the marketplace.json entry, in sync. The plugin cache is keyed on that string, so an unbumped release does not reach anyone who has already installed it, and a marketplace entry version overrides the manifest's if they differ.

Status

Working, confirmed in Ghostty 1.3.2 on Claude Code 2.1.273. Persian renders joined, right-to-left and flush right, Latin runs inside a Persian sentence keep their own direction, and code blocks pass through.

License

MIT

Source 11 files
hooks/register.ts 335 lines
1import type {
2  BuiltinToolResults,
3  EngineInterface,
4  On,
5  PluginOptions,
6  RenderComponent,
7  RenderElement,
8  RenderInput,
9} from 'claude-code'
10
11import { cellWidth } from './cell-width'
12import { fribidiArgv, packStdin, unpackStdout } from './fribidi-args'
13import { LruCache } from './lru-cache'
14import type { Settings } from './options'
15import { settingsOf } from './options'
16import { baseDirection } from './rtl-detect'
17import type { Gutter } from './render-tree'
18import { treeOf } from './render-tree'
19import type { RenderLine, Shaper } from './transform'
20import { transformText } from './transform'
21
22/**
23 * Separates the cache key's fields. A NUL can never appear in a rendered
24 * message, so no text can forge a key boundary. Built with fromCharCode
25 * rather than written as an escape: an editor would otherwise put a raw
26 * NUL in this file and make it a binary file to grep and friends.
27 */
28const KEY_SEPARATOR = String.fromCharCode(0)
29
30/** A terminal render event, the only surface this mod draws on. */
31type TerminalRender = RenderInput<RenderComponent, 'terminal'>
32
33/** What `next` is, once narrowed to the terminal render input. */
34type Next = (e: TerminalRender) => Promise<RenderElement> | RenderElement
35
36/**
37 * Everything the hooks share.
38 *
39 * The helpers below are top-level function declarations taking this as a
40 * parameter rather than closures inside `register`. That is not a style
41 * choice: `claude plugin validate` refuses a module that passes `$` to
42 * anything but a function declared at the top of the file, so it can see
43 * statically which `$` calls a plugin makes.
44 */
45type Context = {
46  settings: Settings
47  cache: LruCache<RenderLine[]>
48  logged: Set<string>
49  disabled: boolean
50  probed: Promise<boolean> | null
51  /** The fribidi the probe actually got a version out of. */
52  resolved: string
53  /** How many times the probe has come back empty-handed. */
54  attempts: number
55}
56
57/** Failed probes tolerated before shaping is switched off for the session. */
58const MAX_PROBE_ATTEMPTS = 3
59
60/**
61 * Where to look for fribidi when the configured name is not runnable.
62 *
63 * Measured: a hooks worker DOES resolve a bare `fribidi` off PATH, so this is
64 * insurance rather than the fix for any known failure. It costs one extra
65 * spawn only on a machine where the bare name misses.
66 */
67const FRIBIDI_PATHS: readonly string[] = [
68  '/opt/homebrew/bin/fribidi',
69  '/usr/local/bin/fribidi',
70  '/usr/bin/fribidi',
71  '/opt/local/bin/fribidi',
72]
73
74/**
75 * Logs a line once and only once.
76 *
77 * `$.ui.log` appends to the transcript, and render hooks re-run on every
78 * redraw and resize, so an undeduplicated log would append a row per frame,
79 * and each row is itself new transcript content.
80 */
81function note($: EngineInterface, ctx: Context, msg: string): void {
82  if (ctx.logged.has(msg)) return
83  ctx.logged.add(msg)
84  $.ui.log(msg)
85}
86
87/** Runs one fribidi call per batch of lines sharing a base direction. */
88function shaperOf($: EngineInterface, ctx: Context): Shaper {
89  return async (lines, dir) => {
90    const { exitCode, stdout, stderr } = await $.process.run(
91      fribidiArgv(dir, ctx.resolved),
92      { stdin: packStdin(lines), timeoutMs: ctx.settings.timeoutMs },
93    )
94    if (exitCode !== 0) throw new Error(stderr)
95
96    const out = unpackStdout(stdout, lines.length)
97    if (!out) throw new Error('fribidi line count mismatch')
98
99    return out
100  }
101}
102
103/**
104 * Looks for a runnable fribidi, reporting why each candidate failed.
105 *
106 * The configured name is tried first, then the usual install prefixes. A
107 * failure is NOT final: the caller clears the memo so the next render tries
108 * again, and only after MAX_PROBE_ATTEMPTS is shaping switched off for the
109 * session. The reported reason matters — a missing binary, a refused call and
110 * a timeout all used to surface as the same unhelpful "not runnable".
111 */
112async function probe($: EngineInterface, ctx: Context): Promise<boolean> {
113  const seen = new Set<string>()
114  const candidates = [ctx.settings.fribidiPath, ...FRIBIDI_PATHS].filter(path => {
115    if (path === '' || seen.has(path)) return false
116    seen.add(path)
117    return true
118  })
119
120  const failures: string[] = []
121
122  for (const path of candidates) {
123    try {
124      const { exitCode, stderr } = await $.process.run([path, '--version'], {
125        timeoutMs: ctx.settings.timeoutMs,
126      })
127      if (exitCode === 0) {
128        ctx.resolved = path
129        return true
130      }
131      failures.push(path + ' exited ' + String(exitCode) + (stderr ? ': ' + stderr.trim() : ''))
132    } catch (err) {
133      // Carry the reason. A bare "not runnable" hides whether this was a
134      // missing binary, a refused call or a timeout, which are three
135      // different bugs.
136      failures.push(path + ' threw ' + (err instanceof Error ? err.message : String(err)))
137    }
138  }
139
140  ctx.attempts += 1
141
142  // Do not latch on the first failure. The probe runs on the first render of
143  // the session, when a transient refusal would otherwise disable shaping for
144  // the whole session with no way back.
145  if (ctx.attempts >= MAX_PROBE_ATTEMPTS) {
146    ctx.disabled = true
147    note($, ctx, 'no runnable fribidi after ' + String(ctx.attempts) + ' tries: ' + failures.join('; '))
148  }
149
150  return false
151}
152
153/**
154 * Shapes `text` and returns the tree to draw, or null to leave the engine's
155 * own row alone.
156 *
157 * `columns` is passed separately from `e` because `viewport` is optional on
158 * the render input: each hook does its own `!e.viewport` guard, which makes
159 * reading `e.viewport.columns` legal there but not in here.
160 */
161async function render(
162  $: EngineInterface,
163  ctx: Context,
164  e: TerminalRender,
165  columns: number,
166  text: string,
167  markdown: boolean,
168  gutter?: Gutter,
169): Promise<RenderElement | null> {
170  const lead = gutter ? cellWidth(gutter.first) : 0
171  const width = Math.max(1, columns - ctx.settings.margin - lead)
172  const key = [String(markdown), String(width), text].join(KEY_SEPARATOR)
173
174  let lines = ctx.cache.get(key)
175
176  if (lines === undefined) {
177    const shaped = await transformText(
178      text,
179      { columns: width, alignment: ctx.settings.alignment, markdown },
180      shaperOf($, ctx),
181    )
182    if (shaped === null) return null
183    ctx.cache.set(key, shaped)
184    lines = shaped
185  }
186
187  const t = await $.ui.resolve(e)
188
189  return treeOf(lines, t, gutter)
190}
191
192/**
193 * The shared hook body: bail out on anything this mod does not handle, then
194 * draw. Any throw is caught by the registration's own `.catch`, which falls
195 * back to the engine's own row.
196 */
197async function draw(
198  $: EngineInterface,
199  ctx: Context,
200  e: TerminalRender,
201  next: Next,
202  text: string,
203  markdown: boolean,
204  gutter?: Gutter,
205): Promise<RenderElement> {
206  if (ctx.disabled || e.surface !== 'terminal' || !e.viewport) return next(e)
207  if (!(await (ctx.probed ??= probe($, ctx)))) {
208    if (!ctx.disabled) ctx.probed = null
209    return next(e)
210  }
211
212  const tree = await render($, ctx, e, e.viewport.columns, text, markdown, gutter)
213
214  return tree ?? next(e)
215}
216
217/**
218 * The reply marker, drawn only on the block that opens a reply.
219 *
220 * Returning our own tree replaces the engine's entire row, marker included, so
221 * successive replies run together unless we draw it ourselves. It sits on the
222 * edge the text starts from: on the right for a right-aligned RTL reply, where
223 * a left-hand marker would sit at the END of the sentence and read as noise.
224 * Set `replyBullet` to an empty string to turn it off.
225 */
226function gutterFor(settings: Settings, isFirstOfReply: boolean, dir: 'rtl' | 'ltr'): Gutter | undefined {
227  if (settings.replyBullet === '') return undefined
228
229  const right = settings.alignment === 'right' || (settings.alignment === 'auto' && dir === 'rtl')
230  const mark = right ? ' ' + settings.replyBullet : settings.replyBullet + ' '
231  const width = cellWidth(mark)
232
233  return {
234    first: isFirstOfReply ? mark : ' '.repeat(width),
235    rest: ' '.repeat(width),
236    side: right ? 'right' : 'left',
237  }
238}
239
240/** The shaped lines as one string, for handing back to the engine's own row. */
241function shapedTextOf(lines: RenderLine[]): string {
242  return lines.map(line => (line.kind === 'code' ? line.source : line.text)).join('\n')
243}
244
245/**
246 * Shapes the text and hands it back through `next`, so the ENGINE draws the
247 * row.
248 *
249 * Used where the engine's own styling is the point: a user message carries a
250 * background band and a prompt marker that an own tree would throw away. The
251 * cost is that the engine may re-wrap, so the text is shaped to a width with
252 * room to spare and every line comes back shorter than the engine's own limit.
253 */
254async function rewrite(
255  $: EngineInterface,
256  ctx: Context,
257  e: TerminalRender,
258  next: Next,
259  text: string,
260  markdown: boolean,
261): Promise<RenderElement> {
262  if (ctx.disabled || e.surface !== 'terminal' || !e.viewport) return next(e)
263  if (!(await (ctx.probed ??= probe($, ctx)))) {
264    if (!ctx.disabled) ctx.probed = null
265    return next(e)
266  }
267
268  // Extra slack beyond `margin`: the engine indents its own row, and a line
269  // that overflows would be re-wrapped, which is what undoes the bidi order.
270  const width = Math.max(1, e.viewport.columns - ctx.settings.margin - 4)
271  const key = ['rw', String(markdown), String(width), text].join(KEY_SEPARATOR)
272
273  let lines = ctx.cache.get(key)
274
275  if (lines === undefined) {
276    const shaped = await transformText(
277      text,
278      { columns: width, alignment: ctx.settings.alignment, markdown },
279      shaperOf($, ctx),
280    )
281    if (shaped === null) return next(e)
282    ctx.cache.set(key, shaped)
283    lines = shaped
284  }
285
286  return next({ ...e, props: { ...e.props, text: shapedTextOf(lines) } } as TerminalRender)
287}
288
289export function register(on: On, options: PluginOptions): void {
290  const settings = settingsOf(options)
291
292  const ctx: Context = {
293    settings,
294    cache: new LruCache<RenderLine[]>(settings.cacheSize),
295    logged: new Set<string>(),
296    disabled: false,
297    probed: null,
298    resolved: settings.fribidiPath,
299    attempts: 0,
300  }
301
302  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) =>
303    draw(
304      $,
305      ctx,
306      e as TerminalRender,
307      next as Next,
308      e.props.text,
309      true,
310      gutterFor(ctx.settings, e.props.isFirstOfReply, baseDirection(e.props.text)),
311    ),
312  ).catch(($, e, next) => next(e))
313
314  // A user row is handed back to the engine rather than drawn here: it carries
315  // a background band and a prompt marker that an own tree would discard.
316  on('ui.render', { component: 'UserMessage' }, async ($, e, next) =>
317    rewrite($, ctx, e as TerminalRender, next as Next, e.props.text, true),
318  ).catch(($, e, next) => next(e))
319
320  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) =>
321    draw($, ctx, e as TerminalRender, next as Next, e.props.text, false),
322  ).catch(($, e, next) => next(e))
323
324  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
325    if (e.props.tool !== 'Bash') return next(e)
326
327    const out = e.props.output as BuiltinToolResults['Bash'] | null
328    // A large Bash output lives in a file; redrawing stdout would present a
329    // truncated view as though it were the whole thing.
330    if (!out || out.persistedOutputPath) return next(e)
331
332    return draw($, ctx, e as TerminalRender, next as Next, out.stdout, false)
333  }).catch(($, e, next) => next(e))
334}
335
hooks/cell-width.ts 72 lines
1/**
2 * Code points that occupy no terminal cell: combining marks, Arabic
3 * diacritics and the harakat, the bidi format controls, ZWJ/ZWNJ, and the
4 * U+FEFF filler fribidi emits after a lam-alef ligature.
5 *
6 * Written as code point numbers rather than a regex class on purpose: the
7 * range needs U+2028-U+202E, and a regex literal holding a raw U+2028 is a
8 * line break to the parser.
9 */
10const ZERO_RANGES: readonly (readonly [number, number])[] = [
11  [0x0300, 0x036f],
12  [0x0483, 0x0489],
13  [0x0591, 0x05bd],
14  [0x05bf, 0x05bf],
15  [0x05c1, 0x05c2],
16  [0x05c4, 0x05c5],
17  [0x05c7, 0x05c7],
18  [0x0610, 0x061a],
19  [0x064b, 0x065f],
20  [0x0670, 0x0670],
21  [0x06d6, 0x06dc],
22  [0x06df, 0x06e4],
23  [0x06e7, 0x06e8],
24  [0x06ea, 0x06ed],
25  [0x200b, 0x200f],
26  [0x2028, 0x202e],
27  [0x2060, 0x2064],
28  [0xfeff, 0xfeff],
29]
30
31/** Code points the terminal draws two cells wide. */
32const WIDE_RANGES: readonly (readonly [number, number])[] = [
33  [0x1100, 0x115f],
34  [0x2e80, 0xa4cf],
35  [0xac00, 0xd7a3],
36  [0xf900, 0xfaff],
37  [0xfe30, 0xfe4f],
38  [0xff00, 0xff60],
39  [0xffe0, 0xffe6],
40]
41
42const within = (code: number, ranges: readonly (readonly [number, number])[]): boolean => {
43  for (const range of ranges) {
44    if (code >= range[0] && code <= range[1]) return true
45  }
46
47  return false
48}
49
50/**
51 * How many terminal cells the string occupies.
52 *
53 * This must agree with what the terminal actually draws, because alignment
54 * padding is computed from it. Task 3.4 checks it against fribidi's own
55 * `--width` padding, which is the independent oracle.
56 */
57export function cellWidth(s: string): number {
58  let width = 0
59
60  for (const char of s) {
61    const code = char.codePointAt(0) ?? 0
62    if (within(code, ZERO_RANGES)) continue
63    if (code >= 0x20000 || within(code, WIDE_RANGES)) {
64      width += 2
65      continue
66    }
67    width += 1
68  }
69
70  return width
71}
72
hooks/fribidi-args.ts 41 lines
1/** The base direction forced on a run of lines. */
2export type Direction = 'rtl' | 'ltr'
3
4/** The U+FEFF filler fribidi emits after a lam-alef ligature. */
5const FILLER = String.fromCharCode(0xfeff)
6
7/**
8 * The argv for one fribidi call.
9 *
10 * `--nopad` because alignment padding is done in TypeScript: fribidi's own
11 * `--width` padding is display-cell accurate, but it cannot express a
12 * per-line `auto` alignment or a base direction chosen from the whole logical
13 * line. `--nobreak` because fribidi's line breaking is NOT word-aware and
14 * will split a word mid-token. `--reordernsm` puts non-spacing marks after
15 * their base character.
16 */
17export function fribidiArgv(direction: Direction, path: string): string[] {
18  return [path, '--nopad', '--nobreak', '--reordernsm', '--' + direction]
19}
20
21/** One line per input line; fribidi converts each independently. */
22export function packStdin(lines: string[]): string {
23  return lines.length === 0 ? '' : lines.map(line => line + '\n').join('')
24}
25
26/**
27 * Parses fribidi's stdout back into lines, returning null when the line count
28 * does not match what was sent. A mismatch means fribidi broke or merged a
29 * line, which would silently corrupt the transcript, so the caller falls back
30 * to the unshaped text rather than drawing something wrong.
31 */
32export function unpackStdout(stdout: string, expected: number): string[] | null {
33  const body = stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout
34  const lines = body.split('\n').map(line => {
35    const withoutCr = line.endsWith('\r') ? line.slice(0, -1) : line
36    return withoutCr.split(FILLER).join('')
37  })
38
39  return lines.length === expected ? lines : null
40}
41
hooks/lru-cache.ts 42 lines
1/**
2 * A bounded least-recently-used cache.
3 *
4 * Render hooks re-run on every redraw and every resize, so a shaped message
5 * must not pay for fribidi again each time. Map preserves insertion order, so
6 * deleting and re-inserting a key on every touch makes the oldest key the
7 * first one the iterator yields.
8 */
9export class LruCache<V> {
10  private readonly capacity: number
11  private readonly entries = new Map<string, V>()
12
13  constructor(capacity: number) {
14    this.capacity = Math.max(1, Math.trunc(capacity))
15  }
16
17  get size(): number {
18    return this.entries.size
19  }
20
21  get(key: string): V | undefined {
22    if (!this.entries.has(key)) return undefined
23
24    const value = this.entries.get(key) as V
25    this.entries.delete(key)
26    this.entries.set(key, value)
27
28    return value
29  }
30
31  set(key: string, value: V): void {
32    if (this.entries.has(key)) this.entries.delete(key)
33    this.entries.set(key, value)
34
35    while (this.entries.size > this.capacity) {
36      const oldest = this.entries.keys().next()
37      if (oldest.done === true) break
38      this.entries.delete(oldest.value)
39    }
40  }
41}
42
hooks/options.ts 53 lines
1import type { PluginOptions } from 'claude-code'
2
3/** How a paragraph is placed in the viewport once it has been shaped. */
4export type Alignment = 'auto' | 'left' | 'right'
5
6/** The mod's settings, every field resolved and clamped. */
7export type Settings = {
8  alignment: Alignment
9  fribidiPath: string
10  margin: number
11  cacheSize: number
12  timeoutMs: number
13  /** Marker drawn on the opening line of a reply; an empty string disables it. */
14  replyBullet: string
15}
16
17const DEFAULTS: Settings = {
18  alignment: 'auto',
19  fribidiPath: 'fribidi',
20  margin: 4,
21  cacheSize: 256,
22  timeoutMs: 2000,
23  replyBullet: String.fromCodePoint(0x23fa),
24}
25
26const ALIGNMENTS: readonly Alignment[] = ['auto', 'left', 'right']
27
28const clamped = (value: unknown, min: number, max: number, fallback: number): number => {
29  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
30  return Math.min(max, Math.max(min, Math.trunc(value)))
31}
32
33/**
34 * Resolves the plugin's options into complete settings, replacing anything
35 * missing or out of range with a default rather than refusing to load.
36 */
37export function settingsOf(options: PluginOptions): Settings {
38  const alignment = options['alignment']
39  const path = options['fribidiPath']
40  const bullet = options['replyBullet']
41
42  return {
43    alignment: ALIGNMENTS.includes(alignment as Alignment)
44      ? (alignment as Alignment)
45      : DEFAULTS.alignment,
46    fribidiPath: typeof path === 'string' && path.length > 0 ? path : DEFAULTS.fribidiPath,
47    margin: clamped(options['margin'], 0, 64, DEFAULTS.margin),
48    cacheSize: clamped(options['cacheSize'], 1, 4096, DEFAULTS.cacheSize),
49    timeoutMs: clamped(options['timeoutMs'], 200, 10000, DEFAULTS.timeoutMs),
50    replyBullet: typeof bullet === 'string' ? bullet : DEFAULTS.replyBullet,
51  }
52}
53
hooks/rtl-detect.ts 32 lines
1/**
2 * The strong-RTL test.
3 *
4 * The `(?=\p{L})` lookahead is what keeps the Arabic-script DIGITS out:
5 * Persian U+06F0-06F9 and Arabic-Indic U+0660-0669 are `\p{Nd}`, not `\p{L}`,
6 * so a line of Persian numerals alone is not RTL. A set-difference class would
7 * say the same thing but needs the ES2024 `v` flag, which `target: es2023`
8 * refuses.
9 */
10const RTL = /(?=\p{L})[\p{Script=Arabic}\p{Script=Hebrew}]/u
11
12/** Any letter at all, used to find the first strong character of a line. */
13const LETTER = /\p{L}/u
14
15/** True when the text holds at least one Arabic or Hebrew letter. */
16export function hasRtl(text: string): boolean {
17  return RTL.test(text)
18}
19
20/**
21 * The line's base direction by the UAX #9 first-strong rule: the first letter
22 * decides, neutrals before it are skipped, and a line with no letter is `ltr`.
23 */
24export function baseDirection(line: string): 'rtl' | 'ltr' {
25  for (const char of line) {
26    if (!LETTER.test(char)) continue
27    return RTL.test(char) ? 'rtl' : 'ltr'
28  }
29
30  return 'ltr'
31}
32
hooks/render-tree.ts 77 lines
1import type { Elements, RenderElement } from 'claude-code'
2import type { RenderLine } from './transform'
3
4/**
5 * The three constructors this mod draws with, taken from the surface's table.
6 *
7 * Elements are NOT hand-built object literals. A `{ type: 'Box', ... }`
8 * literal is type-legal, so tsc would never complain, but the engine refuses
9 * the tree and redraws its own row instead, which looks exactly like the mod
10 * not being loaded at all.
11 */
12export type DrawTable = Pick<Elements['terminal'], 'Box' | 'Text' | 'Code'>
13
14/** CodeProps.source is capped at 10000 characters. */
15const CODE_CAP = 10000
16
17/**
18 * The marker column drawn beside a reply.
19 *
20 * Returning our own tree replaces the engine's whole row, bullet included, so
21 * a reply loses the marker that separates it from the one before unless we
22 * draw it ourselves. `first` goes on the opening line, `rest` keeps every
23 * later line aligned with it, so both must be the same cell width.
24 *
25 * `side` is which edge it sits on. For right-to-left text the line begins at
26 * the RIGHT edge, so a marker on the left sits at the end of the sentence,
27 * which reads as nothing at all; RTL replies put it on the right.
28 */
29export type Gutter = { first: string; rest: string; side: 'left' | 'right' }
30
31/**
32 * Builds the drawn tree: one Text per visual line inside a column Box, and a
33 * Code element for each passed-through code block.
34 *
35 * Every line is already wrapped to the viewport, so `wrap: 'truncate-end'`
36 * should never fire; it is there so that a cell-width disagreement clips one
37 * line rather than reflowing the whole paragraph and undoing the bidi order.
38 */
39export function treeOf(lines: RenderLine[], t: DrawTable, gutter?: Gutter): RenderElement {
40  const children: RenderElement[] = []
41
42  /** Puts the marker on the edge the reading direction starts from. */
43  const withGutter = (text: string): string => {
44    if (!gutter) return text
45    const mark = children.length === 0 ? gutter.first : gutter.rest
46    return gutter.side === 'right' ? text + mark : mark + text
47  }
48
49  for (const line of lines) {
50    if (line.kind === 'code') {
51      if (line.source.length <= CODE_CAP) {
52        children.push(
53          line.language === undefined
54            ? t.Code({ source: line.source })
55            : t.Code({ source: line.source, language: line.language }),
56        )
57        continue
58      }
59
60      // Too long for Code: fall back to plain rows rather than losing it.
61      for (const row of line.source.split('\n')) {
62        children.push(
63          t.Text({ wrap: 'truncate-end', children: [withGutter(row === '' ? ' ' : row)] }),
64        )
65      }
66      continue
67    }
68
69    // An empty Text can collapse the row, so a blank line draws one space.
70    children.push(
71      t.Text({ wrap: 'truncate-end', children: [withGutter(line.text === '' ? ' ' : line.text)] }),
72    )
73  }
74
75  return t.Box({ flexDirection: 'column', children })
76}
77
hooks/transform.ts 148 lines
1import { padLine } from './align'
2import { cellWidth } from './cell-width'
3import type { Direction } from './fribidi-args'
4import type { Alignment } from './options'
5import { baseDirection, hasRtl } from './rtl-detect'
6import {
7  joinTableRow,
8  protectTokens,
9  restoreTokens,
10  splitBlocks,
11  splitPrefix,
12  splitTableRow,
13} from './segment'
14import { wrapBody } from './wrap'
15
16/** Turns logical lines into visual ones. One call per base direction. */
17export type Shaper = (lines: string[], dir: Direction) => Promise<string[]>
18
19/** A line of the drawn tree: prose to print, or a code block to hand to Code. */
20export type RenderLine =
21  | { kind: 'text'; text: string }
22  | { kind: 'code'; source: string; language?: string }
23
24export type TransformOptions = {
25  columns: number
26  alignment: Alignment
27  markdown: boolean
28}
29
30/** A wrapped piece waiting for its shaped form. */
31type Slot = {
32  dir: Direction
33  index: number
34  prefix: string
35  tokens: string[]
36  /** Set for a table cell so the row can be rebuilt once every cell is back. */
37  row?: { at: number; cell: number }
38}
39
40/**
41 * Shapes a markdown message for a terminal that cannot do bidi itself.
42 *
43 * Returns null when there is nothing to do, which is the fast path for an
44 * all-Latin message: the caller then leaves the engine's own row alone.
45 *
46 * The order is the whole point. Wrapping happens in LOGICAL order against the
47 * viewport width, and only then is each finished line shaped. Shaping first
48 * and wrapping the visual result would move the paragraph's opening words to
49 * the last line.
50 */
51export async function transformText(
52  text: string,
53  opts: TransformOptions,
54  shape: Shaper,
55): Promise<RenderLine[] | null> {
56  if (!hasRtl(text)) return null
57
58  const blocks = opts.markdown
59    ? splitBlocks(text)
60    : [{ kind: 'text' as const, lines: text.split('\n') }]
61
62  const out: RenderLine[] = []
63  const pending: Record<Direction, string[]> = { rtl: [], ltr: [] }
64  const slots: (Slot | null)[] = []
65
66  const queue = (body: string, prefix: string, width: number, row?: Slot['row']): void => {
67    const dir = baseDirection(body)
68    for (const piece of wrapBody(body, width)) {
69      const { text: protectedText, tokens } = protectTokens(piece)
70      slots.push({ dir, index: pending[dir].length, prefix, tokens, ...(row ? { row } : {}) })
71      pending[dir].push(protectedText)
72      out.push({ kind: 'text', text: '' })
73    }
74  }
75
76  for (const block of blocks) {
77    if (block.kind === 'code') {
78      slots.push(null)
79      out.push(
80        block.language === undefined
81          ? { kind: 'code', source: block.lines.join('\n') }
82          : { kind: 'code', source: block.lines.join('\n'), language: block.language },
83      )
84      continue
85    }
86
87    for (const line of block.lines) {
88      if (!hasRtl(line)) {
89        slots.push(null)
90        out.push({
91          kind: 'text',
92          text: opts.alignment === 'right' ? padLine(line, opts.columns, 'right', 'ltr') : line,
93        })
94        continue
95      }
96
97      const cells = opts.markdown ? splitTableRow(line) : null
98      if (cells) {
99        const at = out.length
100        slots.push(null)
101        out.push({ kind: 'text', text: '' })
102        cells.forEach((cell, index) => {
103          queue(cell, '', opts.columns, { at, cell: index })
104        })
105        continue
106      }
107
108      const { prefix, body } = opts.markdown ? splitPrefix(line) : { prefix: '', body: line }
109      queue(body, prefix, opts.columns - cellWidth(prefix))
110    }
111  }
112
113  // One subprocess per direction actually used, never one per line.
114  const shaped: Record<Direction, string[]> = { rtl: [], ltr: [] }
115  if (pending.rtl.length > 0) shaped.rtl = await shape(pending.rtl, 'rtl')
116  if (pending.ltr.length > 0) shaped.ltr = await shape(pending.ltr, 'ltr')
117
118  const rows = new Map<number, string[]>()
119
120  slots.forEach((slot, position) => {
121    if (!slot) return
122
123    const visual = shaped[slot.dir][slot.index]
124    if (visual === undefined) return
125
126    const restored = slot.tokens.length === 0 ? visual : restoreTokens(visual, slot.tokens)
127
128    if (slot.row) {
129      const cells = rows.get(slot.row.at) ?? []
130      cells[slot.row.cell] = (cells[slot.row.cell] ?? '') + restored
131      rows.set(slot.row.at, cells)
132      return
133    }
134
135    const width = opts.columns - cellWidth(slot.prefix)
136    out[position] = {
137      kind: 'text',
138      text: slot.prefix + padLine(restored, width, opts.alignment, slot.dir),
139    }
140  })
141
142  for (const [at, cells] of rows) {
143    out[at] = { kind: 'text', text: joinTableRow(cells.map(cell => cell ?? '')) }
144  }
145
146  return out
147}
148
hooks/align.ts 26 lines
1import { cellWidth } from './cell-width'
2import type { Alignment } from './options'
3
4/**
5 * Pads a shaped line so it sits where an RTL reader expects it.
6 *
7 * Padding is done here rather than with fribidi's `--width` because the
8 * choice is per line: `auto` right-aligns only the lines whose own base
9 * direction is RTL, and fribidi has no way to express that. Padding is never
10 * negative, so a line wider than the viewport is returned untouched.
11 */
12export function padLine(
13  visual: string,
14  width: number,
15  alignment: Alignment,
16  dir: 'rtl' | 'ltr',
17): string {
18  const right = alignment === 'right' || (alignment === 'auto' && dir === 'rtl')
19  if (!right) return visual
20
21  const pad = width - cellWidth(visual)
22  if (pad <= 0) return visual
23
24  return ' '.repeat(pad) + visual
25}
26
hooks/segment.ts 244 lines
1/** A run of markdown lines, either prose to shape or code to leave alone. */
2export type Block =
3  | { kind: 'text'; lines: string[] }
4  | { kind: 'code'; lines: string[]; language?: string }
5
6/** Opening or closing fence, with the info string when it opens. */
7const FENCE = /^\s{0,3}(```+|~~~+)\s*(\S*)/
8
9/** Four spaces or a tab, the markdown indented-code prefix. */
10const INDENT = /^(?: {4}|\t)/
11
12/**
13 * Splits markdown into blocks so a caller can shape prose and pass code
14 * through untouched. Fence markers are dropped; an indented block keeps its
15 * text with the indent stripped. An unterminated fence runs to the end.
16 */
17export function splitBlocks(markdown: string): Block[] {
18  if (markdown === '') return []
19
20  const lines = markdown.split('\n')
21  const blocks: Block[] = []
22  let text: string[] = []
23
24  const flushText = (): void => {
25    if (text.length > 0) {
26      blocks.push({ kind: 'text', lines: text })
27      text = []
28    }
29  }
30
31  for (let i = 0; i < lines.length; i += 1) {
32    const line = lines[i] ?? ''
33    const fence = FENCE.exec(line)
34
35    if (fence) {
36      flushText()
37
38      const marker = fence[1] ?? '```'
39      const language = fence[2] ?? ''
40      const body: string[] = []
41
42      i += 1
43      while (i < lines.length) {
44        const inner = lines[i] ?? ''
45        const closing = FENCE.exec(inner)
46        if (closing && (closing[1] ?? '').startsWith(marker[0] ?? '`') && (closing[2] ?? '') === '') {
47          break
48        }
49        body.push(inner)
50        i += 1
51      }
52
53      blocks.push(language === '' ? { kind: 'code', lines: body } : { kind: 'code', lines: body, language })
54      continue
55    }
56
57    const previousIsBlank = text.length > 0 && (text[text.length - 1] ?? '') === ''
58    if (INDENT.test(line) && previousIsBlank) {
59      flushText()
60
61      const body: string[] = []
62      while (i < lines.length && INDENT.test(lines[i] ?? '')) {
63        body.push((lines[i] ?? '').replace(INDENT, ''))
64        i += 1
65      }
66      i -= 1
67
68      blocks.push({ kind: 'code', lines: body })
69      continue
70    }
71
72    text.push(line)
73  }
74
75  flushText()
76
77  return blocks
78}
79
80/** A line's leading markdown markers, kept out of the shaped run. */
81export type PrefixSplit = { prefix: string; body: string }
82
83/**
84 * One leading marker: a bullet (with an optional task checkbox), an ordered
85 * marker, a heading, or a blockquote. Applied repeatedly so `> - ` composes.
86 */
87const PREFIX = /^(\s*(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d{1,3}[.)]\s+|#{1,6}\s+|>\s?))/
88
89/**
90 * Peels the leading markdown markers off a line. They stay at the logical
91 * start of the row; only `body` is reordered, so a bullet never ends up on
92 * the wrong side of its own text.
93 */
94export function splitPrefix(line: string): PrefixSplit {
95  let prefix = ''
96  let body = line
97
98  for (;;) {
99    const match = PREFIX.exec(body)
100    if (!match) break
101    const taken = match[1] ?? ''
102    if (taken === '') break
103    prefix += taken
104    body = body.slice(taken.length)
105  }
106
107  return { prefix, body }
108}
109
110/** A body with its unshapeable runs lifted out. */
111export type ProtectedBody = { text: string; tokens: string[] }
112
113/**
114 * Runs fribidi must never see.
115 *
116 * Inline code and URLs are opaque: they have to come back byte-identical, so
117 * the whole run is lifted out and never looked inside again.
118 *
119 * Emphasis is a different problem. `*` and `_` are neutral characters, so
120 * bidi floats each marker away from the words it wraps and a reply comes back
121 * with stray `**` at the wrong ends. Only the MARKERS are lifted out: a
122 * placeholder is made of letters, which is bidi class L, so it stays on its
123 * own edge of the span. The text between them has to stay in the line,
124 * because staying in the line is the only way it gets shaped at all. Lifting
125 * the whole span out instead leaves its Persian in logical order and the
126 * terminal then prints that run backwards.
127 */
128const TOKEN =
129  '(`[^`\\n]+`)|(https?://[^\\s<>()\\]]+)|(\\*\\*)([^*\\n]+)(\\*\\*)|(__)([^_\\n]+)(__)|(\\*)([^*\\n]+)(\\*)'
130
131const OPEN = String.fromCharCode(0xe000)
132const CLOSE = String.fromCharCode(0xe001)
133
134/** Encodes an index as a..z, aa..az, ba.. so a placeholder carries its own id. */
135const letters = (index: number): string => {
136  let n = index
137  let out = ''
138  do {
139    out = String.fromCharCode(97 + (n % 26)) + out
140    n = Math.floor(n / 26) - 1
141  } while (n >= 0)
142  return out
143}
144
145/** Lifts one run out and returns the placeholder that stands in for it. */
146const lift = (tokens: string[], run: string): string => {
147  const placeholder = OPEN + letters(tokens.length) + CLOSE
148  tokens.push(run)
149  return placeholder
150}
151
152/**
153 * One pass over a body, recursing into what an emphasis span wraps.
154 *
155 * The regex is rebuilt per call on purpose: a global regex carries its own
156 * `lastIndex`, and a nested `replace` with the shared object would reset the
157 * position the outer pass is walking.
158 */
159const protect = (body: string, tokens: string[]): string =>
160  body.replace(new RegExp(TOKEN, 'g'), (match, ...groups: (string | undefined)[]) => {
161    // An opaque run: inline code, then URL.
162    if (groups[0] !== undefined || groups[1] !== undefined) return lift(tokens, match)
163
164    // The three emphasis alternatives, each an (open, inner, close) triple.
165    for (let at = 2; at + 2 < groups.length; at += 3) {
166      const open = groups[at]
167      const inner = groups[at + 1]
168      const close = groups[at + 2]
169      if (open === undefined || inner === undefined || close === undefined) continue
170      return lift(tokens, open) + protect(inner, tokens) + lift(tokens, close)
171    }
172
173    return match
174  })
175
176/**
177 * Replaces the runs fribidi must not reorder with private-use placeholders.
178 * The placeholders are letters, so the bidi algorithm treats each one as a
179 * plain LTR run and keeps its code points adjacent and in order; the index is
180 * encoded in the placeholder itself, so a run that bidi moves elsewhere in
181 * the line still restores to the right token.
182 */
183export function protectTokens(body: string): ProtectedBody {
184  const tokens: string[] = []
185
186  return { text: protect(body, tokens), tokens }
187}
188
189const PLACEHOLDER = new RegExp(OPEN + '([a-z]+)' + CLOSE, 'g')
190
191const indexOfLetters = (code: string): number => {
192  let n = 0
193  for (const char of code) n = n * 26 + (char.charCodeAt(0) - 97 + 1)
194  return n - 1
195}
196
197/** Puts the protected runs back, wherever bidi left their placeholders. */
198export function restoreTokens(visual: string, tokens: string[]): string {
199  return visual.replace(PLACEHOLDER, (whole, code: string) => {
200    const token = tokens[indexOfLetters(code)]
201    return token === undefined ? whole : token
202  })
203}
204
205/** A table row: `| a | b |`, but not the `|---|---|` separator. */
206const TABLE_ROW = /^\s*\|.*\|\s*$/
207const TABLE_SEPARATOR = /^\s*\|?(\s*:?-+:?\s*\|)+\s*$/
208
209/**
210 * Splits a table row into its cells, or returns null when the line is not a
211 * row (or is the separator). Each cell is shaped on its own so the pipes keep
212 * their columns. An escaped `\|` stays inside its cell.
213 */
214export function splitTableRow(line: string): string[] | null {
215  if (!TABLE_ROW.test(line) || TABLE_SEPARATOR.test(line)) return null
216
217  const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '')
218  const cells: string[] = []
219  let cell = ''
220
221  for (let i = 0; i < inner.length; i += 1) {
222    const char = inner[i] ?? ''
223    if (char === '\\' && inner[i + 1] === '|') {
224      cell += '\\|'
225      i += 1
226      continue
227    }
228    if (char === '|') {
229      cells.push(cell.trim())
230      cell = ''
231      continue
232    }
233    cell += char
234  }
235  cells.push(cell.trim())
236
237  return cells
238}
239
240/** Joins shaped cells back into a row. */
241export function joinTableRow(cells: string[]): string {
242  return '| ' + cells.join(' | ') + ' |'
243}
244
hooks/wrap.ts 73 lines
1import { cellWidth } from './cell-width'
2
3/** Inline code spans and URLs are atomic: wrapping must not split them. */
4const ATOMIC = /^(?:`[^`\n]+`|https?:\/\/[^\s<>()\]]+)$/
5
6/** Breaks one oversize token into width-sized pieces, measured in cells. */
7const hardBreak = (token: string, width: number): string[] => {
8  const pieces: string[] = []
9  let piece = ''
10
11  for (const char of token) {
12    if (cellWidth(piece + char) > width && piece !== '') {
13      pieces.push(piece)
14      piece = char
15      continue
16    }
17    piece += char
18  }
19
20  if (piece !== '') pieces.push(piece)
21
22  return pieces
23}
24
25/**
26 * Greedy word wrap measured in terminal cells, in LOGICAL order.
27 *
28 * Wrapping happens before shaping on purpose. Shaping first and wrapping the
29 * visual result would put the paragraph's opening words on the last line;
30 * this order keeps line 1 the start of the paragraph. A width of zero or less
31 * means no wrapping at all.
32 */
33export function wrapBody(body: string, width: number): string[] {
34  if (width <= 0) return [body]
35  if (body === '') return ['']
36
37  const lines: string[] = []
38  let line = ''
39
40  const flush = (): void => {
41    if (line !== '') {
42      lines.push(line)
43      line = ''
44    }
45  }
46
47  for (const token of body.split(' ')) {
48    if (token === '') continue
49
50    const candidate = line === '' ? token : line + ' ' + token
51
52    if (cellWidth(candidate) <= width) {
53      line = candidate
54      continue
55    }
56
57    flush()
58
59    if (cellWidth(token) <= width || ATOMIC.test(token)) {
60      line = token
61      continue
62    }
63
64    const pieces = hardBreak(token, width)
65    for (let i = 0; i < pieces.length - 1; i += 1) lines.push(pieces[i] ?? '')
66    line = pieces[pieces.length - 1] ?? ''
67  }
68
69  flush()
70
71  return lines.length === 0 ? [''] : lines
72}
73