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.

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.

Claude Code mods are early access and off by default. This one does nothing at all until you set
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1on 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.
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.
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.
| Terminal | Use this mod? | |
|---|---|---|
| Ghostty | Yes — tested | No bidi shipped; #1442 open |
| kitty | Expected yes | #2109 open since 2019 |
| Alacritty | Expected yes | #663 open since 2017 |
| foot | Expected yes | #756, declined by the maintainer |
| Windows Terminal | Expected yes | #538 open since 2019 |
| VS Code terminal (xterm.js) | Expected yes | vscode#271615 |
| iTerm2 | Only with its own RTL off | 3.6+ has experimental RTL under Settings → General → Experimental; off by default |
| WezTerm | Only with bidi_enabled = false | That is the default |
| macOS Terminal.app | No | Native bidi via CoreText; would double-reverse |
| GNOME Terminal / VTE | No | Bidi since VTE 0.58 |
| Konsole | No | Bug 403729 resolved fixed |
| mlterm | No | Bidi 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.
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.
| Component | Shaped |
|---|---|
AssistantMessage | yes, as markdown |
UserMessage | yes, as markdown, handed back to the engine so it keeps its background band |
CommandOutput | yes, as plain text |
ToolResult (Bash only) | yes, as plain text |
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.
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.
| Option | Default | Meaning |
|---|---|---|
alignment | auto | auto right-aligns only paragraphs whose own base direction is RTL; left shapes without padding; right always right-aligns |
fribidiPath | fribidi | Path to the binary |
margin | 4 | Cells 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 |
cacheSize | 256 | Shaped messages kept in the LRU |
timeoutMs | 2000 | How 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 |
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.
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:
--width breaking is not word-aware and splits words mid-token, so every call passes --nobreak and the wrapping is ours.--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.
.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.
$.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.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.claude -p '/plugin-types'.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.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.
MIT
hooks/register.ts 335 lines1import 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}
335hooks/cell-width.ts 72 lines1/**
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}
72hooks/fribidi-args.ts 41 lines1/** 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}
41hooks/lru-cache.ts 42 lines1/**
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}
42hooks/options.ts 53 lines1import 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}
53hooks/rtl-detect.ts 32 lines1/**
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}
32hooks/render-tree.ts 77 lines1import 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}
77hooks/transform.ts 148 lines1import { 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}
148hooks/align.ts 26 lines1import { 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}
26hooks/segment.ts 244 lines1/** 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}
244hooks/wrap.ts 73 lines1import { 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