Right-to-left Hebrew, Arabic and Persian for the Claude Code terminal

Right-to-left Hebrew, Arabic and Persian in the Claude Code terminal.
A Claude Code mod that displays Hebrew, Arabic and Persian right-to-left and aligned to the right, while English words, numbers, file paths and code inside the text stay left-to-right. Without it, most terminals show this text in reverse order.
<a href="assets/hebrew-1.png"><img src="assets/hebrew-1.png" width="32%" alt="Hebrew reply with the mod, part 1: paragraphs, mixed English, numbers and brackets"></a> <a href="assets/hebrew-2.png"><img src="assets/hebrew-2.png" width="32%" alt="Hebrew reply with the mod, part 2: lists, a quote, a table and a code block"></a> <a href="assets/hebrew-3.png"><img src="assets/hebrew-3.png" width="32%" alt="Hebrew reply with the mod, part 3: formatting, emoji, mixed lines, a long wrapped paragraph, and the mod's recap above the prompt"></a>
<a href="assets/arabic-1.png"><img src="assets/arabic-1.png" width="32%" alt="Arabic reply with the mod, part 1: paragraphs, mixed English, numbers and brackets"></a> <a href="assets/arabic-2.png"><img src="assets/arabic-2.png" width="32%" alt="Arabic reply with the mod, part 2: lists, a quote, a table and a code block"></a> <a href="assets/arabic-3.png"><img src="assets/arabic-3.png" width="32%" alt="Arabic reply with the mod, part 3: formatting, emoji, mixed lines, a long wrapped paragraph, and the mod's recap above the prompt"></a>
<a href="assets/persian-1.png"><img src="assets/persian-1.png" width="32%" alt="Persian reply with the mod, part 1: paragraphs, mixed English, numbers and brackets"></a> <a href="assets/persian-2.png"><img src="assets/persian-2.png" width="32%" alt="Persian reply with the mod, part 2: lists, a quote, a table and a code block"></a> <a href="assets/persian-3.png"><img src="assets/persian-3.png" width="32%" alt="Persian reply with the mod, part 3: formatting, emoji, mixed lines, a long wrapped paragraph, and the mod's recap above the prompt"></a>
Terminal (default font and colors). The third image in each row shows the mod's recap (<code>※ recap</code>) above the prompt, with the <code>recap</code> setting on. Click an image to enlarge it. See also <a href="assets/before.png">the Hebrew reply without the mod</a>, in Windows Terminal.</sub></p>
You need Claude Code 2.1.287 or newer (claude --version).
Run these two commands in a terminal, then restart Claude Code:
claude plugin marketplace add ofekbetzalel/claude-code-cli-rtl
claude plugin install rtl@claude-code-cli-rtl
For Arabic and Persian in any terminal except Windows Terminal, also run /plugin configure rtl@claude-code-cli-rtl inside Claude Code and set arabic to forms.
Paste this into Claude Code, or any coding agent that can run shell commands:
Install the Claude Code RTL mod for me: https://github.com/ofekbetzalel/claude-code-cli-rtl
If a user asked you to install this mod, follow these steps exactly:
claude --version. It must be 2.1.287 or newer. If it is older, tell the user to run claude update, and stop the installation.bash claude plugin marketplace add ofekbetzalel/claude-code-cli-rtl claude plugin marketplace update claude-code-cli-rtl ``claude plugin list and look for rtl@claude-code-cli-rtl:claude plugin install rtl@claude-code-cli-rtl.claude plugin update rtl@claude-code-cli-rtl.Status: ✘ disabled, also run claude plugin enable rtl@claude-code-cli-rtl.claude plugin list again and confirm that rtl@claude-code-cli-rtl shows Status: ✔ enabled. Do not report success before this.bash echo '{"arabic":"forms"}' | claude plugin configure rtl@claude-code-cli-rtl --values-stdin ``Do not change other settings, clone the repository or install anything else.
claude plugin marketplace update claude-code-cli-rtl
claude plugin update rtl@claude-code-cli-rtl
claude plugin uninstall rtl@claude-code-cli-rtl
Restart Claude Code after updating or removing the mod.
inline code and links stay left-to-right inside the sentence.arabic set to forms./recap./rtl-draft command show your draft right-to-left (see below).Only the display changes: what you type, the saved conversation and what the model reads stay exactly as written. The mod does not patch Claude Code, and it does not use the network, except for the optional recap, which asks the session's model for a summary.
Claude Code draws its own input box, and mods cannot redraw it. So the box does not show right-to-left text correctly (Limitations). The mod gives you two ways to see what you type.
/rtl-draft opens a pane that shows your whole draft right-to-left, with a marker at the cursor. In Claude Code's fullscreen view, when the terminal is about 110 columns wide or wider, the pane opens beside the conversation. Otherwise, it opens above the prompt. It opens immediately, even while Claude is replying. Run /rtl-draft again or click ✕ in the pane to close it.
pane; at the bottom, Claude Code's own input box.</sub></p>
The band above the prompt lets you see what you type right-to-left without opening /rtl-draft. It is on by default (the preview setting) and appears whenever your draft contains right-to-left letters. While the /rtl-draft pane is open, the band does not show the draft.
Tested on Windows 11 with Ubuntu in WSL and Claude Code 2.1.289. Screenshots from each terminal.
| Windows Terminal | WezTerm | Konsole ¹ | mintty ² | Browser (xterm.js) | |
|---|---|---|---|---|---|
| Hebrew reads right-to-left | ✅ | ✅ | ✅ | ❌ | ✅ |
| Lines aligned right | ✅ | ✅ | ✅ | ✅ | ✅ |
| English and numbers in place | ✅ | ✅ | ✅ | ✅ | ✅ |
| Arabic and Persian letters joined | ✅ | ✅ ³ | ✅ ³ | ✅ ³ | ✅ ³ |
-o Bidi=0). Hebrew letters still come out reversed inside each word.arabic set to forms.In Windows Terminal and the VS Code terminal, Claude Code reorders right-to-left text itself. The mod detects this and draws its lines so that they come out in the right order, so start Claude Code as usual.
Not tested yet: macOS Terminal, iTerm2, GNOME Terminal and the VS Code terminal itself (the mod's output for it was checked cell by cell, but not on screen). If right-to-left text looks reversed with the mod on, your terminal is probably reordering it a second time: turn off its bidi option if it has one, or set order to logical. Reports from other terminals are welcome.
Change them inside Claude Code, in /config (the mod's rows end in · rtl) or with /plugin configure rtl@claude-code-cli-rtl. The names in bold are the ones /config shows. The mod also adds the /rtl-draft command (Seeing what you type).
| Setting | Values | What it does |
|---|---|---|
Character order<br>order | visual (default), logical, off | visual: the mod puts the text in display order itself, for terminals that do not reorder text. logical: only align right, for terminals that reorder text themselves. off: Claude Code draws text as usual. |
Paragraph direction<br>direction | rtl-share (default), first-strong | rtl-share: a paragraph is right-to-left if its first letter is right-to-left, or if at least 30% of its letters are (inline code not counted). first-strong: only the first letter decides (the Unicode rule). |
Text color for RTL rows<br>textColor | #rrggbb, or empty | The color of right-to-left text. Set it if right-to-left text does not match the color of the rest of the text. |
Prompt preview<br>preview | on (default), draft, off | The band above the prompt shows what you type, and Claude Code's dim suggestion, right-to-left while they hold right-to-left letters. draft: only what you type. off: no band. |
Recap band<br>recap | off (default), on | on: after you have been away for about four minutes, the mod shows its own recap above the prompt, right-to-left when the recap is. It asks for the recap in the language of your messages, but the model decides. Turn off Claude Code's own Session recap first (/config): while it is on, the band stays hidden and the mod shows a note saying so. Each recap asks the session's model once more, which can take more than one request. |
Arabic and Persian letters<br>arabic | letters (default), forms | letters: plain letters, which the terminal joins (Windows Terminal does). forms: the mod writes each letter in its joined shape, for the other terminals. Hebrew is not affected. |
/rtl-draft or the band above the prompt (Seeing what you type). Once you send it, your message is drawn right-to-left.※ recap:) cannot be reached by mods, so it reads backwards. Turn it off in /config (Session recap) and set the mod's recap to on to get a right-to-left recap instead. The /recap command's output is drawn right-to-left.dark-ansi and light-ansi themes, which can show right-to-left words reversed.arabic at letters. Its default font has no glyphs for the joined shapes that forms writes. More about fonts in docs/design/arabic-persian.md.Claude Code 2.1.287 added mods: plugins that can replace how parts of the screen are drawn. This mod redraws replies and messages that contain right-to-left letters. It runs the Unicode Bidirectional Algorithm on each paragraph, wraps it to the terminal width, and writes every line in the order it is read, aligned to the right. The details are in How it works.
Bug reports with a screenshot, the text, and your terminal's name help the most. See CONTRIBUTING.md.
This is an independent project, not affiliated with Anthropic.
hooks/register.tsx 805 lines1import type { Hook, Register } from 'claude-code'
2
3import { engineSegments } from '../src/bidi.ts'
4import { COMMENT, STRING } from '../src/code.ts'
5import { isolateRow, nudge, paletteFor, pieceProps, propsKey, rowHasRtl, type AttrKey, type Piece } from '../src/isolate.ts'
6import type { Span } from '../src/layout.ts'
7import { BOLD, CODE, DIM, ITALIC, STRIKE, UNDERLINE } from '../src/markdown.ts'
8import { BULLET, COMMAND_PREFIX, LEAF_MAX, Memo, modelAssistant, modelCommand, modelUser, shouldDraw, USER_PREFIX, type Model, type RowModel, type Settings, type UserModel } from '../src/render.ts'
9import { settingsFrom } from '../src/options.ts'
10import { BAND_PREFIX, BoxMirror, CURSOR_STYLE, modelDraft, modelPreview, POLL_MS, type BandRow } from '../src/preview.ts'
11import { capRecap, modelRecap, RECAP_ASK, RECAP_DELAY_MS, RecapGate } from '../src/recap.ts'
12import { graphemes } from '../src/width.ts'
13
14
15// A tree keeps the engine's bounds or it is not drawn (2.1.288's validator): 20,000 nodes, string
16// children included; 100,000 characters of text; 10,000 characters per string child, Code source
17// or Markdown text. The counts below follow the JSX of this file exactly. Ticks cost about one node
18// per RTL letter: past the node budget with them, a message is drawn without them (right in a
19// terminal; a browser terminal then reverses its words, and a note says so). Past the budgets
20// without them, the engine draws the message and a note says why.
21const NODE_BUDGET = 19_500
22const TEXT_BUDGET = 99_000
23
24type Props = Record<string, boolean | string>
25type TextRow = Extract<RowModel, { kind: 'text' }>
26
27// The recap band's counts, kept in the session for a reload of the mod.
28// The recap band's counts, kept in the session for a reload of the mod. Saves run one after another,
29// each with the counts as they were when it was asked, so an older one never lands last; none while
30// the session's kept counts are being read, and none once the session changed.
31function keepCounts($: Engine, gate: RecapGate, queue: { last: Promise<void> }): void {
32 if (gate.loading) return
33 const counts = gate.counts()
34 const epoch = gate.epoch
35 queue.last = queue.last.then(async () => {
36 try {
37 const session = await $.session.id()
38 if (gate.epoch === epoch) await $.state.set(RECAP_STATE, { session, ...counts })
39 } catch {
40 // a session that ended meanwhile keeps nothing
41 }
42 })
43}
44
45// Only set props are passed: the engine validates element props against an allowlist.
46function styleProps(style: number): Props {
47 const p: Props = {}
48 if (style & BOLD) p.bold = true
49 if (style & ITALIC) p.italic = true
50 if (style & STRIKE) p.strikethrough = true
51 if (style & UNDERLINE) p.underline = true
52 if (style & DIM) p.dimColor = true
53 return p
54}
55
56// A string child longer than the engine takes is cut at cluster boundaries; null when one cluster
57// alone is longer.
58function fitStrings(text: string): string[] | null {
59 if (text.length <= LEAF_MAX) return [text]
60 const out: string[] = []
61 let cur = ''
62 for (const g of graphemes(text)) {
63 if (g.length > LEAF_MAX) return null
64 if (cur.length + g.length > LEAF_MAX) {
65 out.push(cur)
66 cur = ''
67 }
68 cur += g
69 }
70 if (cur) out.push(cur)
71 return out
72}
73
74// A drawn piece: a bare string (the row's own attributes) or a Text with its own, each with its
75// string children cut to size.
76type Drawn = { props: Props | null; strings: string[] }
77
78// Claude Code reorders RTL text itself when it takes the terminal for one that cannot (Windows
79// Terminal's `WT_SESSION`, or `TERM_PROGRAM=vscode`): each Text on its own, its nested Texts with it
80// (src/bidi.ts `engineReorder`). There a row is drawn as sibling Texts of one direction each, the
81// row's attributes on every one, holding what that reorder turns back into the row; null when a
82// segment does not fit a string child.
83function segmentRow(drawn: Drawn[], outer: Props | null): Drawn[] | null {
84 const out: Drawn[] = []
85 for (const d of drawn) {
86 const props = outer || d.props ? { ...(outer ?? {}), ...(d.props ?? {}) } : null
87 for (const seg of engineSegments(d.strings.join(''))) {
88 const strings = fitStrings(seg)
89 if (strings === null) return null
90 out.push({ props, strings })
91 }
92 }
93 return out
94}
95
96// The draft pane's id, and the cells it asks for beside the transcript.
97const DRAFT_PANE = 'rtl-draft'
98const DRAFT_COLUMNS = 48
99
100const MERMAID = /(^|\n) {0,3}(`{3,}|~{3,})[ \t]*mermaid/
101
102type Shown = { text: string; hint: boolean }
103// the engine interface a hook receives
104type Engine = Parameters<Hook<'session.start'>>[0]
105// the recap band's counts in the session (types/index.d.ts)
106const RECAP_STATE = { plugin: 'rtl', key: 'recap' } as const
107
108// The native recap's `/config` row: on, or unknown, means no band.
109function nativeOff(rows: { key: string; value: unknown }[]): boolean {
110 const row = rows.find(r => r.key === 'recap' || r.key === 'awaySummaryEnabled')
111 return row !== undefined && row.value === false
112}
113// Said when the band is on but the native recap's row is on too, so the band stays hidden. Short: a
114// toast shows about three rows of 40 columns and cuts the rest.
115const NATIVE_ON_NOTE = "Recap band is hidden while Claude Code's own Session recap is on. Turn Session recap off in /config."
116// The `/config` rows, or none when they cannot be read: then no toast and no theme change, and the
117// hook goes on (a setting already saved still answers with its result).
118async function configRows($: Engine): Promise<{ key: string; value: unknown }[]> {
119 try {
120 return await $.config.list()
121 } catch {
122 return []
123 }
124}
125// What `run` answers, or `fallback` when it throws (a surface without that noun).
126async function attempt<T>(run: () => Promise<T>, fallback: T): Promise<T> {
127 try {
128 return await run()
129 } catch {
130 return fallback
131 }
132}
133// Whether Claude Code reorders RTL text itself: its own test (2.1.289's `isNeeded()`), on the
134// variables it reads. Unreadable means no.
135async function engineReorders($: Engine): Promise<boolean> {
136 return attempt(async () => (await $.env.get('WT_SESSION')) !== undefined || (await $.env.get('TERM_PROGRAM')) === 'vscode', false)
137}
138// Reads the box every POLL_MS, for the changes that raise no event: the band is redrawn when the text
139// changed and holds RTL letters or held them, the draft pane (`pane()` true while it is open) when the
140// text or the cursor changed.
141function pollBox($: Engine, box: BoxMirror, pane: () => boolean): { cancel: () => void } {
142 return $.clock.every(POLL_MS, () => {
143 void (async () => {
144 try {
145 const token = box.readStart()
146 const before = box.shows()
147 const now = await $.prompt.read()
148 if (box.read(token, now.text, now.cursor) && (pane() || before || box.shows())) $.ui.invalidate('ui.render')
149 } catch {
150 // the session ended or the mod reloaded while it read
151 }
152 })()
153 })
154}
155function nativeOn(rows: { key: string; value: unknown }[]): boolean {
156 const row = rows.find(r => r.key === 'recap' || r.key === 'awaySummaryEnabled')
157 return row !== undefined && row.value !== false
158}
159
160export const register: Register = (on, options) => {
161 const { order, settings, textColor, recap, preview, suggestion } = settingsFrom(options)
162 const memo = new Memo<Model | null>()
163 const userMemo = new Memo<UserModel | null>()
164 const commandMemo = new Memo<UserModel | null>()
165 // Rows are shared through the block memo, so their pieces are kept per row object and palette.
166 const pieceMemo = new WeakMap<TextRow, { key: string; pieces: Piece[] }>()
167 let theme: string | undefined
168 // whether Claude Code reorders RTL text itself (segmentRow), as read at the session's start
169 let reorders = false
170 // the draft pane is open (`/rtl-draft`), and drawn since it opened (its first drawing redraws the
171 // band); the band leaves the draft to it only while the engine shows it, placed
172 let drafting = false
173 let paneDrawn = false
174 type UI = { Box: ReturnType<Engine['ui']['resolve']>['Box']; Text: ReturnType<Engine['ui']['resolve']>['Text'] }
175 // A row's pieces and what they cost in nodes beyond the row's own Text: as they are, or where
176 // Claude Code reorders, its segments.
177 const settle = (drawn: Drawn[], outer: Props | null): { drawn: Drawn[]; nodes: number } | null => {
178 if (!reorders) return { drawn, nodes: drawn.reduce((n, d) => n + d.strings.length + (d.props ? 1 : 0), 0) }
179 const segs = segmentRow(drawn, outer)
180 return segs && { drawn: segs, nodes: segs.reduce((n, d) => n + d.strings.length + 1, 0) }
181 }
182 // A row as drawn: one Text with the pieces in it, or the segments side by side in a Box.
183 const drawRow = ({ Box, Text }: UI, drawn: Drawn[], outer: Props | null) =>
184 reorders ? (
185 <Box>{drawn.map(d => <Text {...(d.props ?? {})}>{d.strings}</Text>)}</Box>
186 ) : (
187 <Text {...(outer ?? {})}>{drawn.map(d => (d.props ? <Text {...d.props}>{d.strings}</Text> : d.strings))}</Text>
188 )
189
190 // The recap band (option `recap`, off by default; docs/design/how-it-works.md). Claude Code's own
191 // automatic recap is a row no mod can draw, so with its `/config` row off this draws one above the
192 // prompt instead: when the engine sends its idle notification ("Claude is waiting for your input":
193 // the turn and the background work it tracks are done, and the person has not touched the terminal
194 // since), it waits three minutes more, then asks the session's model for the native recap's summary
195 // over the conversation. Any sign of the person in between cancels it. Nothing is stored in the
196 // conversation: the band is cleared by the next prompt, and gone after a resume. The counts behind
197 // the prompts rule and the hint are kept in the session (`$.state`), so a reload keeps them.
198 let enabled = recap && order !== 'off'
199 const gate = new RecapGate()
200 const saves = { last: Promise.resolve() }
201 let timer: { cancel: () => void } | null = null
202 let shown: Shown | null = null
203 // The prompt preview (option `preview`, on by default; src/preview.ts): the box's text as the mod
204 // last saw it, and the dim suggestion while the box is empty. Visual order only: a terminal that
205 // reorders text itself (`order: logical`) shows the box right already.
206 const previewing = preview && order === 'visual'
207 const box = new BoxMirror()
208 let poll: { cancel: () => void } | null = null
209 const stopPoll = () => {
210 poll?.cancel()
211 poll = null
212 }
213 const disarm = () => {
214 timer?.cancel()
215 timer = null
216 }
217 // the band cleared: whether it was showing, to be drawn again
218 const hide = () => {
219 const was = shown !== null
220 shown = null
221 return was
222 }
223
224 on('session.start', async ($, e, next) => {
225 // what waited belongs to the session before, or to the module before this load
226 gate.reset(true)
227 const epoch = gate.epoch
228 disarm()
229 shown = null
230 box.reset()
231 stopPoll()
232 const result = await next(e)
233 const was = reorders
234 reorders = order === 'visual' && (await engineReorders($))
235 drafting = await attempt(async () => (await $.ui.panes()).some(p => p.id === DRAFT_PANE), false)
236 if (reorders !== was || drafting) $.ui.invalidate('ui.render')
237 // immediate: typed while a turn runs, it opens or closes the pane at once (it touches nothing else)
238 if (order !== 'off') await attempt(() => $.command.register({ name: 'rtl-draft', description: 'Show or hide your draft right-to-left in a side pane', immediate: true }), undefined)
239 const rows = await configRows($)
240 const row = rows.find(r => r.key === 'theme')
241 if (row && typeof row.value === 'string' && row.value !== theme) {
242 theme = row.value
243 $.ui.invalidate('ui.render')
244 }
245 if (enabled && nativeOn(rows)) $.ui.toast(NATIVE_ON_NOTE, { timeoutMs: 10_000 })
246 // this session's counts, from before a reload of the mod (a new session has none)
247 const kept = (await $.state.get(RECAP_STATE)).value
248 // what was counted meanwhile is added, then saved with the rest
249 if (gate.restore(kept, await $.session.id(), epoch)) keepCounts($, gate, saves)
250 return result
251 })
252
253 on('session.end', async ($, e, next) => {
254 gate.reset(false)
255 disarm()
256 const before = box.shows()
257 box.reset()
258 stopPoll()
259 if (hide() || before) $.ui.invalidate('ui.render')
260 return next(e)
261 })
262
263 on('config.set', { key: 'theme' }, async ($, e, next) => {
264 const result = await next(e)
265 if (typeof result.value === 'string' && result.value !== theme) {
266 theme = result.value
267 $.ui.invalidate('ui.render')
268 }
269 return result
270 })
271
272 // The person's own prompt rows (typed here, or sent through Remote Control), in both views:
273 // the row is the prompt's whole text, as the engine draws it. Notifications and other agents'
274 // messages keep the engine's drawing.
275 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
276 const origin = e.props.origin.kind
277 if (order === 'off' || e.surface !== 'terminal' || (origin !== 'composer' && origin !== 'bridge') || !shouldDraw(e.props.text)) return next(e)
278 const { Box, Text } = $.ui.resolve(e)
279 const columns = e.viewport?.columns ?? 0
280 const model = userMemo.get(`${order}|${settings.base}|${settings.arabic}|${columns}|${e.props.text}`, () => modelUser(e.props.text, columns, settings))
281 const fallback = async (why: string) => (
282 <Box flexDirection="column">
283 {await next(e)}
284 <Text dimColor>{` rtl: ${why}`}</Text>
285 </Box>
286 )
287 if (model === null) return fallback('too narrow to draw right-to-left')
288 const palette = paletteFor(theme, textColor)
289 // The user row sits on its own background, so the tick goes there: the text keeps the
290 // terminal's default color, and the background moves one unit.
291 const tickBg = palette && settings.mode === 'visual' ? nudge(palette.userBg) : null
292 const attr: AttrKey = (_style, tick) => (tick ? 'tick' : 'row')
293 const isolated = model.rows.map(r => (tickBg && rowHasRtl(r.spans) ? isolateRow(r.spans, attr) : null))
294 const plan = (ticks: boolean) => {
295 let nodes = 1
296 let chars = 0
297 const rows: Drawn[][] = []
298 for (let i = 0; i < model.rows.length; i++) {
299 const r = model.rows[i]
300 const pieces = ticks && isolated[i] ? isolated[i]! : r.spans.map(s => ({ ...s, tick: false }))
301 const drawn: Drawn[] = []
302 for (const p of pieces) {
303 const strings = fitStrings(p.text)
304 if (strings === null) return null
305 drawn.push({ props: p.tick ? { backgroundColor: tickBg! } : null, strings })
306 chars += p.text.length
307 }
308 const settled = settle(drawn, null)
309 if (settled === null) return null
310 // row Box, prefix Box, its Text and string, content Box, its Text
311 nodes += settled.nodes + 6
312 chars += 2
313 rows.push(settled.drawn)
314 }
315 return nodes <= NODE_BUDGET && chars <= TEXT_BUDGET ? rows : null
316 }
317 const withTicks = tickBg ? plan(true) : null
318 const drawnRows = withTicks ?? plan(false)
319 if (drawnRows === null) return fallback('too long to draw right-to-left')
320 const note = tickBg && withTicks === null ? <Text dimColor>{' rtl: too long to isolate; a browser terminal may reverse its words'}</Text> : null
321 // The engine's own row opens with an empty line (its marginTop); ours keeps it.
322 return (
323 <Box flexDirection="column" width={model.width} marginTop={1}>
324 {model.rows.map((r, index) => (
325 <Box width={model.width} backgroundColor="userMessageBackground">
326 <Box width={USER_PREFIX}><Text dimColor>{r.first ? '❯ ' : ' '}</Text></Box>
327 <Box flexGrow={1} paddingRight={1} justifyContent={r.rtl ? 'flex-end' : 'flex-start'}>
328 {drawRow({ Box, Text }, drawnRows[index], null)}
329 </Box>
330 </Box>
331 ))}
332 {note}
333 </Box>
334 )
335 })
336
337 // A slash command's output row with RTL text (the summary `/recap` prints), as the engine draws
338 // it: a dim `⎿` on the left, then the text, its RTL rows right-aligned and in visual order, in
339 // the theme's text color with the color runs. An error row keeps the engine's drawing.
340 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
341 if (order === 'off' || e.surface !== 'terminal' || e.props.isErrored || !shouldDraw(e.props.text)) return next(e)
342 const { Box, Text } = $.ui.resolve(e)
343 const columns = e.viewport?.columns ?? 0
344 const model = commandMemo.get(`${order}|${settings.base}|${settings.arabic}|${columns}|${e.props.text}`, () => modelCommand(e.props.text, columns, settings))
345 const fallback = async (why: string) => (
346 <Box flexDirection="column">
347 {await next(e)}
348 <Text dimColor>{` rtl: ${why}`}</Text>
349 </Box>
350 )
351 if (model === null) return fallback('too narrow to draw right-to-left')
352 const palette = paletteFor(theme, textColor)
353 const colorable = palette !== null && settings.mode === 'visual'
354 const attr: AttrKey = (style, tick) => propsKey(pieceProps(style, tick, palette!))
355 const baseKey = palette ? propsKey(pieceProps(0, false, palette)) : ''
356 const plan = (ticks: boolean) => {
357 let nodes = 1
358 let chars = 0
359 const rows: { drawn: Drawn[]; colored: boolean }[] = []
360 for (const r of model.rows) {
361 const colored = ticks && rowHasRtl(r.spans)
362 const pieces = colored ? isolateRow(r.spans, attr) : r.spans.map(sp => ({ ...sp, tick: false }))
363 const drawn: Drawn[] = []
364 for (const p of pieces) {
365 const strings = fitStrings(p.text)
366 if (strings === null) return null
367 let props: Props | null = null
368 if (colored) {
369 const full = pieceProps(p.style, p.tick, palette!)
370 props = propsKey(full) === baseKey ? null : (full as Props)
371 } else if (p.style) {
372 props = styleProps(p.style)
373 if (p.style & CODE) props.color = 'permission'
374 }
375 drawn.push({ props, strings })
376 chars += p.text.length
377 }
378 const settled = settle(drawn, colored ? { color: palette!.text } : null)
379 if (settled === null) return null
380 // row Box, prefix Box, its Text and string, content Box, its Text
381 nodes += settled.nodes + 6
382 chars += COMMAND_PREFIX
383 rows.push({ drawn: settled.drawn, colored })
384 }
385 return nodes <= NODE_BUDGET && chars <= TEXT_BUDGET ? rows : null
386 }
387 const withTicks = colorable ? plan(true) : null
388 const drawnRows = withTicks ?? plan(false)
389 if (drawnRows === null) return fallback('too long to draw right-to-left')
390 const note = colorable && withTicks === null ? <Text dimColor>{' rtl: too long to isolate; a browser terminal may reverse its words'}</Text> : null
391 return (
392 <Box flexDirection="column" width={model.width}>
393 {model.rows.map((r, index) => {
394 const { drawn, colored } = drawnRows[index]
395 return (
396 <Box width={model.width}>
397 <Box width={COMMAND_PREFIX}><Text dimColor>{r.first ? ' ⎿ ' : ' '}</Text></Box>
398 <Box flexGrow={1} paddingRight={1} justifyContent={r.rtl ? 'flex-end' : 'flex-start'}>
399 {drawRow({ Box, Text }, drawn, colored ? { color: palette!.text } : null)}
400 </Box>
401 </Box>
402 )
403 })}
404 {note}
405 </Box>
406 )
407 })
408
409 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
410 if (order === 'off' || e.surface !== 'terminal' || !shouldDraw(e.props.text)) return next(e)
411 const { Box, Text, Code, Markdown } = $.ui.resolve(e)
412 const fallback = async (why: string) => (
413 <Box flexDirection="column">
414 {await next(e)}
415 <Text dimColor>{` rtl: ${why}`}</Text>
416 </Box>
417 )
418 const columns = e.viewport?.columns ?? 0
419 const key = `${order}|${settings.base}|${settings.arabic}|${columns}|${e.props.isFirstOfReply}|${e.props.text}`
420 const model = memo.get(key, () => modelAssistant(e.props.text, columns, e.props.isFirstOfReply, settings))
421 if (model === null) {
422 return fallback(MERMAID.test(e.props.text) ? 'a mermaid diagram, drawn as Claude Code draws it' : 'too narrow or too long to draw right-to-left')
423 }
424 const palette = paletteFor(theme, textColor)
425 const colorable = palette !== null && settings.mode === 'visual'
426 const paletteKey = palette ? `${palette.text}|${palette.code}` : ''
427 // A colored row's pieces are cut by their final attributes (isolate.ts).
428 const attr: AttrKey = (style, tick) => propsKey(pieceProps(style, tick, palette!))
429 const baseKey = palette ? propsKey(pieceProps(0, false, palette)) : ''
430
431 // Rows that hold RTL text (and every table row) are drawn in explicit colors, the theme's text
432 // color and its tick, so that their letters can be isolated; other rows keep the terminal's
433 // default color. A row is isolated whole, prefix and body in visual order.
434 const visualSpans = (r: TextRow) => (r.rtl ? [...r.spans, ...r.lead] : [...r.lead, ...r.spans])
435 const isolatedRow = (r: TextRow) => {
436 const kept = pieceMemo.get(r)
437 if (kept && kept.key === paletteKey) return kept.pieces
438 const pieces = isolateRow(visualSpans(r), attr)
439 pieceMemo.set(r, { key: paletteKey, pieces })
440 return pieces
441 }
442 const isolated = model.rows.map(r => (r.kind === 'text' && colorable && (r.paint || rowHasRtl(r.spans)) ? isolatedRow(r) : null))
443
444 // In a colored row the row's Text carries the base attributes, so a piece with exactly those is
445 // a bare string. In a plain row, a piece without style is.
446 const drawPiece = (p: Piece, colored: boolean): Drawn | null => {
447 const strings = fitStrings(p.text)
448 if (strings === null) return null
449 if (colored) {
450 const props = pieceProps(p.style, p.tick, palette!)
451 return { props: propsKey(props) === baseKey ? null : (props as Props), strings }
452 }
453 if (p.style === 0) return { props: null, strings }
454 const props = styleProps(p.style)
455 if (p.style & BULLET) props.color = 'text'
456 else if (p.style & COMMENT) props.color = 'green'
457 else if (p.style & STRING) props.color = 'red'
458 else if (p.style & CODE) props.color = 'permission'
459 return { props, strings }
460 }
461 // The tree's rows as drawn, with or without ticks; null past the budgets.
462 const plan = (ticks: boolean) => {
463 let nodes = 1
464 let chars = 0
465 const rows: (Drawn[] | null)[] = []
466 for (let i = 0; i < model.rows.length; i++) {
467 const r = model.rows[i]
468 if (r.kind === 'text') {
469 const colored = ticks && isolated[i] !== null
470 const pieces = colored ? isolated[i]! : visualSpans(r).map(sp => ({ ...sp, tick: false }))
471 const drawn: Drawn[] = []
472 for (const p of pieces) {
473 const d = drawPiece(p, colored)
474 if (d === null) return null
475 drawn.push(d)
476 chars += p.text.length
477 }
478 const settled = settle(drawn, colored ? { color: palette!.text } : null)
479 if (settled === null) return null
480 nodes += settled.nodes + 2 // row Box, row Text
481 rows.push(settled.drawn)
482 } else {
483 // blank: Box, Text, string; code and markdown: Box, gutter Box, Text, string, Box, leaf
484 nodes += r.kind === 'blank' ? 3 : r.kind === 'code' && r.inset ? 7 : 6
485 chars += 1 + (r.kind === 'code' ? r.source.length : r.kind === 'markdown' ? r.text.length : 0)
486 rows.push(null)
487 }
488 }
489 return nodes <= NODE_BUDGET - 3 && chars <= TEXT_BUDGET ? { rows, ticks } : null
490 }
491 const withTicks = colorable ? plan(true) : null
492 const chosen = withTicks ?? plan(false)
493 if (chosen === null) return fallback('too long to draw right-to-left')
494 const note = colorable && withTicks === null ? <Text dimColor>{' rtl: too long to isolate; a browser terminal may reverse its words'}</Text> : null
495
496 // The engine's own message opens with an empty line (its marginTop); ours keeps it.
497 return (
498 <Box flexDirection="column" width={model.width} marginTop={1}>
499 {model.rows.map((r, index) => {
500 switch (r.kind) {
501 case 'blank':
502 return <Box><Text> </Text></Box>
503 case 'code': {
504 const code = <Code {...(r.language ? { source: r.source, language: r.language } : { source: r.source })} />
505 return (
506 <Box width={model.width}>
507 <Box width={r.indent}><Text color={palette ? palette.text : 'text'}>{r.bullet ? '●' : ' '}</Text></Box>
508 <Box flexGrow={1}>{code}</Box>
509 {r.inset ? <Box width={r.inset} /> : null}
510 </Box>
511 )
512 }
513 case 'markdown':
514 return (
515 <Box width={model.width}>
516 <Box width={2}><Text color={palette ? palette.text : 'text'}>{r.bullet ? '●' : ' '}</Text></Box>
517 <Box flexGrow={1}><Markdown text={r.text} /></Box>
518 </Box>
519 )
520 case 'text': {
521 const colored = chosen.ticks && isolated[index] !== null
522 const text = drawRow({ Box, Text }, chosen.rows[index]!, colored ? { color: palette!.text } : null)
523 return r.rtl ? (
524 <Box width={model.width} justifyContent="flex-end">
525 {text}
526 </Box>
527 ) : (
528 <Box width={model.width}>{text}</Box>
529 )
530 }
531 }
532 })}
533 {note}
534 </Box>
535 )
536 })
537
538 on('config.set', async ($, e, next) => {
539 const recapKey = e.key === 'rtl.recap' || e.key === 'recap' || e.key === 'awaySummaryEnabled'
540 // any change to the band or the native recap voids what waits, before the change lands
541 if (recapKey) {
542 gate.touch()
543 disarm()
544 }
545 const result = await next(e)
546 if (e.key === 'rtl.recap') enabled = result.value === 'on' && order !== 'off'
547 // the native recap turned on, or the band turned off: nothing more from the band
548 const off = (e.key === 'rtl.recap' && !enabled) || ((e.key === 'recap' || e.key === 'awaySummaryEnabled') && result.value !== false)
549 if (off && hide()) $.ui.invalidate('ui.render')
550 // the band turned on over the native recap, or the native recap turned on under the band
551 if (recapKey && enabled && (e.key === 'rtl.recap' ? nativeOn(await configRows($)) : result.value !== false)) {
552 $.ui.toast(NATIVE_ON_NOTE, { timeoutMs: 10_000 })
553 }
554 return result
555 })
556
557 on('prompt.submit', async ($, e, next) => {
558 // any prompt, from anywhere, entered or not, voids what waits
559 gate.submit()
560 disarm()
561 const redraw = hide() || (e.origin.kind === 'composer' && box.shows())
562 if (e.origin.kind === 'composer') box.sent()
563 if (redraw) $.ui.invalidate('ui.render')
564 const result = await next(e)
565 // the person's own prompt that entered counts toward the next recap (a dropped one does not)
566 if ((e.origin.kind === 'composer' || e.origin.kind === 'bridge') && result.drop === undefined) {
567 gate.prompt()
568 keepCounts($, gate, saves)
569 }
570 return result
571 })
572
573 on('prompt.edit', async ($, e, next) => {
574 gate.touch()
575 disarm()
576 const before = box.shows()
577 const generation = box.editStart()
578 let after = null
579 try {
580 after = await next(e)
581 } finally {
582 box.editEnd(generation, after?.text ?? null, after?.cursor)
583 }
584 if (drafting || (previewing && (before || box.shows()))) $.ui.invalidate('ui.render')
585 return after
586 })
587
588 // Claude Code's dim suggestion, about to show in the empty box: kept for the preview as this hook
589 // sees it, never changed (Tab would insert a changed text). A plugin beneath may still change it.
590 on('prompt.suggest', async ($, e, next) => {
591 const offer = box.offer()
592 const result = await next(e)
593 if (previewing && suggestion && result.isShown) {
594 const before = box.shows()
595 if (box.suggested(offer, e.text) && (before || box.shows())) $.ui.invalidate('ui.render')
596 }
597 return result
598 })
599
600 // A main-loop turn: a subagent's run raises no turn.start, nor does the band's own `$.model.fork`
601 // (seen on 2.1.289). It voids what waits, hides a recap already shown, and ends the suggestion.
602 on('turn.start', async ($, e, next) => {
603 gate.turnStart()
604 disarm()
605 const before = box.shows()
606 box.dropGhost()
607 if (hide() || (before && !box.shows())) $.ui.invalidate('ui.render')
608 return next(e)
609 })
610
611 on('turn.complete', async (_$, e, next) => {
612 const result = await next(e)
613 if (e.agentId === undefined) gate.turnEnd()
614 return result
615 })
616
617 on('classic.Notification', async ($, e, next) => {
618 const result = await next(e)
619 if (!enabled || e.notification_type !== 'idle_prompt' || !gate.idle()) return result
620 const revision = gate.revision
621 disarm()
622 // Each wait below lets the conversation move on, so whether this revision is still the one is
623 // asked again after every one of them, the last time with no wait before the band is shown.
624 const live = () => enabled && gate.live(revision)
625 // what the band cannot see while it waits, checked when it would ask and again with the answer:
626 // the native recap, a draft, agents still at work
627 const quiet = async () =>
628 live() && nativeOff(await $.config.list()) &&
629 live() && (await $.prompt.read()).text === '' &&
630 live() && !(await $.agent.list()).some(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting') &&
631 live()
632 timer = $.clock.after(RECAP_DELAY_MS, () => {
633 timer = null
634 void (async () => {
635 try {
636 if (!live() || (await $.session.surface()) !== 'terminal' || !(await quiet())) return
637 const reply = await $.model.fork({ prompt: RECAP_ASK })
638 // a late answer for a conversation that moved on is dropped (its usage is spent)
639 if (!(await quiet()) || !live()) return
640 const text = reply.isAnswered ? capRecap(reply.text) : ''
641 if (!text) {
642 gate.failed()
643 return
644 }
645 shown = { text, hint: gate.shown() }
646 keepCounts($, gate, saves)
647 $.ui.invalidate('ui.render')
648 } catch {
649 // the session ended or the mod reloaded while it waited: nothing to show
650 }
651 })()
652 })
653 return result
654 })
655
656 // Some changes of the box raise no `prompt.edit`: Tab taking the suggestion, a prompt recalled
657 // from history, an outside editor. The hint line under the prompt is drawn again when the box
658 // turns from empty to holding text or back (`isDraft`): the band is redrawn then and reads the
659 // box. Only on that turn, so this redraw does not call itself again. While the box holds text, it
660 // is also read every POLL_MS, for the changes that do not turn it empty (a later step through
661 // history), and the band is redrawn when that text changed and holds RTL letters or held them.
662 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
663 if (order === 'off' || e.surface !== 'terminal' || !box.hint(e.props.isDraft)) return next(e)
664 if (previewing || drafting) $.ui.invalidate('ui.render')
665 stopPoll()
666 if (box.holding && (previewing || drafting)) poll = pollBox($, box, () => drafting)
667 return next(e)
668 })
669
670 // A band row's pieces. RTL rows carry their letters in color runs (isolate.ts), so a browser
671 // terminal keeps them in order; other rows keep the terminal's own text color, dim where the
672 // native row is. The draft pane's cursor bar (CURSOR_STYLE) is drawn bold in the accent color.
673 // Where Claude Code reorders, the pieces become its segments (segmentRow).
674 const bandPieces = (r: BandRow): Drawn[] => {
675 const palette = paletteFor(theme, textColor)
676 const colorable = palette !== null && settings.mode === 'visual'
677 const attr: AttrKey = (style, tick) => propsKey(pieceProps(style, tick, palette!))
678 const bar = (style: number, props: Props | null): Props | null => (style & CURSOR_STYLE ? { ...(props ?? {}), color: 'permission', bold: true } : props)
679 const drawn: Drawn[] = colorable && rowHasRtl(r.spans)
680 ? isolateRow(r.spans, attr).map(p => ({ props: bar(p.style, pieceProps(p.style, p.tick, palette!) as Props), strings: [p.text] }))
681 : r.spans.map(sp => {
682 const props = styleProps(sp.style & (DIM | BOLD | ITALIC))
683 return { props: bar(sp.style, Object.keys(props).length ? props : null), strings: [sp.text] }
684 })
685 return reorders ? (segmentRow(drawn, null) ?? []) : drawn
686 }
687
688 // The draft pane: Claude Code draws the box in logical order, so `/rtl-draft` opens a pane beside
689 // the transcript with the draft laid out right-to-left and a bar at the cursor. The keys stay in
690 // the box; the pane follows each edit. While it is open the band leaves the draft to it. Below
691 // 110 columns, or outside fullscreen, the engine seats it above the prompt instead.
692 // A failed open or close changes nothing and says so.
693 on('command.run', { command: 'rtl-draft' }, async ($) => {
694 if (drafting) {
695 const closed = await attempt(async () => {
696 await $.ui.close({ id: DRAFT_PANE })
697 return true
698 }, false)
699 if (!closed) return { text: 'The draft pane could not be closed. Its ✕ closes it too.' }
700 drafting = false
701 paneDrawn = false
702 $.ui.invalidate('ui.render')
703 return { text: 'Draft pane closed.' }
704 }
705 const opened = await attempt(() => $.ui.open({ id: DRAFT_PANE, title: 'Draft', columns: DRAFT_COLUMNS }), null)
706 if (opened === null) return { text: 'The draft pane could not be opened.' }
707 paneDrawn = false
708 drafting = true
709 stopPoll()
710 if (box.holding) poll = pollBox($, box, () => drafting)
711 $.ui.invalidate('ui.render')
712 return { text: opened.isPlaced ? 'Draft pane open. /rtl-draft again closes it.' : `Draft pane waits: ${opened.reason}` }
713 })
714
715 on('ui.close', { id: DRAFT_PANE }, async ($, e, next) => {
716 const result = await next(e)
717 // a close refused beneath leaves the pane open
718 if ('deny' in result) return result
719 drafting = false
720 paneDrawn = false
721 $.ui.invalidate('ui.render')
722 return result
723 })
724
725 on('ui.render', { component: 'Pane', requestId: DRAFT_PANE }, async ($, e) => {
726 const { Box, Text } = $.ui.resolve(e)
727 const width = e.props.bodyColumns
728 // the box as it is now, unless an edit, a send, a new session or a later read overtook the read
729 const token = box.readStart()
730 const read = await $.prompt.read()
731 box.read(token, read.text, read.cursor)
732 const { text, cursor } = box.current(token) ? read : { text: box.draft, cursor: box.cursor }
733 // drawn at last (opened on a narrow terminal, then widened): the band leaves the draft to it now
734 if (!paneDrawn) {
735 paneDrawn = true
736 $.ui.invalidate('ui.render')
737 }
738 const model = modelDraft(text, cursor, width, settings)
739 if (model === null) return <Text dimColor>{'rtl: too narrow'}</Text>
740 // the rows that fit, the cursor's among them
741 const fit = Math.max(1, e.props.scroll.bodyRows)
742 const from = Math.max(0, Math.min(model.cursorRow - fit + 1, model.rows.length - fit))
743 const rows = model.rows.slice(from, from + fit)
744 return (
745 <Box flexDirection="column" width={width}>
746 {rows.map(r => {
747 const drawn = bandPieces(r)
748 return (
749 <Box width={width} justifyContent={r.rtl ? 'flex-end' : 'flex-start'}>
750 {/* an empty line of the draft keeps its row */}
751 {drawn.length ? drawRow({ Box, Text }, drawn, null) : <Text>{' '}</Text>}
752 </Box>
753 )
754 })}
755 {text === '' ? <Box width={width} justifyContent="flex-end"><Text dimColor>{'Type in the prompt box; it shows here.'}</Text></Box> : null}
756 </Box>
757 )
758 })
759
760 // The band above the prompt: the recap, then the preview of the box, closest to it.
761 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
762 if (e.surface !== 'terminal' || e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
763 const columns = e.props.bodyColumns
764 const maxRows = e.props.maxRows
765 // while Claude Code works there is no suggestion and no recap; a draft typed meanwhile is shown
766 const working = e.props.isWorking
767 // the preview first (the box is what the person works on now), the recap above it if it fits whole
768 let previewRows: BandRow[] = []
769 // with the draft pane on screen the draft is drawn there, not twice
770 const paneShown = drafting && (await attempt(async () => (await $.ui.panes()).some(p => p.id === DRAFT_PANE && p.isShown && p.isPlaced), false))
771 if (previewing && !paneShown && (box.shows() || box.holding)) {
772 // the box as it is now: a change the mod did not see as an edit shows too, and is kept
773 const token = box.readStart()
774 const seen = await $.prompt.read()
775 const read = seen.text
776 box.read(token, read, seen.cursor)
777 // a read overtaken by an edit, a send, a new session or a later read: the mirror's text instead
778 const now = box.current(token) ? read : box.draft
779 previewRows = (now !== '' ? modelPreview(now, false, columns, maxRows, settings) : box.ghost !== null && !working ? modelPreview(box.ghost, true, columns, maxRows, settings) : null) ?? []
780 }
781 const recapModel = shown && !working ? modelRecap(shown.text, shown.hint, columns, settings) : null
782 const band: BandRow[] = recapModel && recapModel.rows.length + previewRows.length <= maxRows ? recapModel.rows.map(r => ({ spans: r.spans, rtl: recapModel.rtl, mark: r.first ? '※' : ' ' })) : []
783 band.push(...previewRows)
784 if (band.length === 0) return next(e)
785 const { Box, Text } = $.ui.resolve(e)
786 const rows = band.map(r => {
787 const mark = <Box width={BAND_PREFIX}><Text dimColor>{r.rtl ? ` ${r.mark}` : `${r.mark} `}</Text></Box>
788 const text = (
789 <Box flexGrow={1} justifyContent={r.rtl ? 'flex-end' : 'flex-start'}>
790 {drawRow({ Box, Text }, bandPieces(r), null)}
791 </Box>
792 )
793 return <Box width={columns}>{r.rtl ? [text, mark] : [mark, text]}</Box>
794 })
795 // the engine's own band below ours: an engine node may not sit under a Box with a width
796 const rest = await next(e)
797 return (
798 <Box flexDirection="column">
799 {rows}
800 {rest}
801 </Box>
802 )
803 })
804}
805src/bidi.ts 297 lines1// Unicode Bidirectional Algorithm (UAX #9). Level resolution comes from the vendored bidi-js
2// (Unicode 13 data); the line rules L1 and L2 are applied here, on grapheme clusters.
3//
4// Why not bidi-js's own line API: `getReorderSegments` applies L1 with paragraph-absolute indices
5// on a line-local slice (vendor/bidi.mjs, the loop after `lineLevels = ...slice`), so lines after
6// the first keep the wrong level for trailing whitespace; and its reordered output works on
7// UTF-16 code units, which tears niqqud from letters and splits emoji sequences. We take the
8// resolved levels only, and reverse whole clusters.
9//
10// Why a proxy string: bidi-js classifies `string[i]`, one UTF-16 unit at a time, so each half of
11// a supplementary scalar (an emoji, a Cypriot or Adlam letter) gets the class of a lone surrogate
12// (L) instead of the scalar's own. We resolve levels on a proxy with one BMP character per scalar,
13// standing in for its class, and map the levels back. The proxy is also where internal isolates
14// (LRI…PDI around inline code) and the line separators of soft breaks live: they take part in
15// resolution and never reach the display text.
16
17import bidiFactory from '../vendor/bidi.mjs'
18import { graphemes } from './width.ts'
19
20export type Direction = 'rtl' | 'ltr'
21
22// How a paragraph's base direction is chosen: forced, UAX #9 P2/P3 (`first-strong`), or P2/P3
23// plus the share rule (`rtl-share`): a paragraph whose letters outside inline code are at least
24// `share` RTL is RTL even when its first strong letter is not.
25export type Base = Direction | 'first-strong' | 'rtl-share'
26
27type Levels = { levels: Uint8Array; paragraphs: { start: number; end: number; level: number }[] }
28
29const bidi = bidiFactory()
30
31// A BMP character of each bidi class, for supplementary scalars in the proxy. ON is `!` (not a
32// bracket, not mirrored); B becomes a line separator (WS) so the proxy stays one paragraph.
33const REPRESENTATIVE: Record<string, string> = {
34 L: 'a', R: '\u05D0', AL: '\u0627', EN: '0', ES: '+', ET: '#', AN: '\u0660', CS: ',', NSM: '\u0300',
35 BN: '\u00AD', B: '\u2028', S: '\t', WS: ' ', ON: '!', LRE: '\u202A', RLE: '\u202B', PDF: '\u202C',
36 LRO: '\u202D', RLO: '\u202E', LRI: '\u2066', RLI: '\u2067', FSI: '\u2068', PDI: '\u2069',
37}
38
39const LRI = '\u2066'
40const PDI = '\u2069'
41
42export function classOf(scalar: string): string {
43 return bidi.getBidiCharTypeName(scalar) as string
44}
45
46export type Resolved = { level: number; levels: Uint8Array }
47
48// A readability rule on top of UAX #9 ("Tokens stay whole" in docs/design/how-it-works.md).
49// Inside RTL prose, a neutral character glued to a
50// Latin token is pulled to the far side of the token by N1/N2: `ghp_` reads `_ghp`, `src/`
51// reads `/src`, `--dry-run` reads `dry-run--`, `.gitignore` loses its dot, `"System"` loses its
52// closing quote. In a run of non-space characters without RTL letters that holds a Latin letter,
53// such neutrals at the run's edges are resolved as L, so the token stays whole. Left alone: a
54// Hebrew prefix hyphen (`ל-`), sentence punctuation that ends the run, and brackets (UAX #9 N0
55// pairs them correctly).
56const EDGE_NEUTRAL = new Set(['ON', 'CS', 'ES', 'ET'])
57const SENTENCE_END = new Set(['.', ',', ';', ':', '!', '?', '\u2026', '\u060C', '\u061B', '\u061F'])
58const PREFIX_HYPHEN = new Set(['-', '\u05BE'])
59
60function isBracket(ch: string): boolean {
61 return bidi.openingToClosingBracket(ch) !== null || bidi.closingToOpeningBracket(ch) !== null
62}
63
64function gluedToLatin(scalars: string[], classes: string[], skip: (i: number) => boolean): boolean[] {
65 const out = new Array<boolean>(scalars.length).fill(false)
66 let i = 0
67 while (i < scalars.length) {
68 if (skip(i) || classes[i] === 'WS' || classes[i] === 'S' || classes[i] === 'B' || classes[i] === 'R' || classes[i] === 'AL') {
69 i++
70 continue
71 }
72 let j = i
73 while (j < scalars.length && !skip(j) && !['WS', 'S', 'B', 'R', 'AL'].includes(classes[j])) j++
74 // [i, j): a run of non-space characters with no RTL letter
75 let first = -1
76 let last = -1
77 for (let k = i; k < j; k++) {
78 if (classes[k] === 'L' || classes[k] === 'EN' || classes[k] === 'AN') {
79 if (first < 0) first = k
80 last = k
81 }
82 }
83 const hasLatin = first >= 0 && scalars.slice(i, j).some((c, n) => classes[i + n] === 'L' && /\p{L}/u.test(c))
84 if (hasLatin) {
85 let a = i
86 if (a > 0 && (classes[a - 1] === 'R' || classes[a - 1] === 'AL') && PREFIX_HYPHEN.has(scalars[a])) a++
87 for (let k = a; k < first; k++) if (EDGE_NEUTRAL.has(classes[k]) && !isBracket(scalars[k])) out[k] = true
88 let b = j
89 while (b > last + 1 && (SENTENCE_END.has(scalars[b - 1]) || bidi.closingToOpeningBracket(scalars[b - 1]) !== null)) b--
90 for (let k = last + 1; k < b; k++) if (EDGE_NEUTRAL.has(classes[k]) && !isBracket(scalars[k])) out[k] = true
91 }
92 i = j
93 }
94 return out
95}
96
97// Resolves one paragraph. `text` is the display text (may contain `\n` for forced line breaks
98// inside the paragraph); `isolated[i]` marks code units that sit inside an LTR isolate (inline
99// code). Returns the paragraph level and a level per code unit of `text`. `plain` leaves out the
100// readability rule below UAX #9 (tokens kept whole), for what another implementation does.
101export function resolveParagraph(text: string, isolated: Uint8Array | null, base: Base, share = 0.3, plain = false): Resolved {
102 const scalars: string[] = []
103 const starts: number[] = []
104 for (let i = 0; i < text.length; ) {
105 const ch = String.fromCodePoint(text.codePointAt(i)!)
106 scalars.push(ch)
107 starts.push(i)
108 i += ch.length
109 }
110 const classes = scalars.map(classOf)
111 const glued = plain ? scalars.map(() => false) : gluedToLatin(scalars, classes, k => isolated !== null && isolated[starts[k]] === 1)
112 let proxy = ''
113 const unitToProxy = new Int32Array(text.length)
114 let inIsolate = false
115 let rtlLetters = 0
116 let letters = 0
117 for (let k = 0; k < scalars.length; k++) {
118 const i = starts[k]
119 const scalar = scalars[k]
120 const n = scalar.length
121 const iso = isolated !== null && isolated[i] === 1
122 if (iso !== inIsolate) {
123 proxy += iso ? LRI : PDI
124 inIsolate = iso
125 }
126 const cls = glued[k] ? 'L' : classes[k]
127 if (!iso && /\p{L}/u.test(scalar)) {
128 letters++
129 if (cls === 'R' || cls === 'AL') rtlLetters++
130 }
131 unitToProxy[i] = proxy.length
132 if (n === 2) unitToProxy[i + 1] = proxy.length
133 proxy += cls === 'B' || scalar === '\n' ? '\u2028' : glued[k] ? 'a' : n === 2 ? (REPRESENTATIVE[cls] ?? '!') : scalar
134 }
135 if (inIsolate) proxy += PDI
136 const run = (dir: Direction | undefined) => bidi.getEmbeddingLevels(proxy, dir) as Levels
137 let result = run(base === 'rtl' || base === 'ltr' ? base : undefined)
138 let level = result.paragraphs[0]?.level ?? (base === 'rtl' ? 1 : 0)
139 if (base === 'rtl-share' && level === 0 && letters > 0 && rtlLetters / letters >= share) {
140 result = run('rtl')
141 level = 1
142 }
143 const levels = new Uint8Array(text.length)
144 for (let i = 0; i < text.length; i++) levels[i] = result.levels[unitToProxy[i]] ?? level
145 return { level, levels }
146}
147
148// What L1 does with a cluster: 0 nothing; 1 whitespace-like (WS, isolate and embedding controls,
149// BN), reset when it precedes a segment separator or ends the line; 2 a segment separator (tab),
150// always reset. Decided by the cluster's first scalar.
151export function l1Kind(cluster: string): 0 | 1 | 2 {
152 const t = classOf(String.fromCodePoint(cluster.codePointAt(0) ?? 32))
153 if (t === 'S') return 2
154 if (t === 'WS' || t === 'B' || t === 'BN' || t === 'LRI' || t === 'RLI' || t === 'FSI' || t === 'PDI' || t === 'LRE' || t === 'RLE' || t === 'LRO' || t === 'RLO' || t === 'PDF') return 1
155 return 0
156}
157
158// The visual order of a line's clusters. `levels[i]` is cluster i's resolved level (that of its
159// first scalar), `kinds[i]` its L1 kind. L1 resets segment separators, and whitespace before them
160// or at the line's end, to the paragraph level; L2 reverses every maximal run at or above each
161// level from the highest down to the lowest odd one. Returns cluster indices, left to right, and
162// each cluster's final level.
163export function reorderLine(levels: number[], kinds: number[], paragraphLevel: number): { order: number[]; final: number[] } {
164 const final = levels.slice()
165 let resetting = true // at the line's end
166 for (let i = final.length - 1; i >= 0; i--) {
167 if (kinds[i] === 2) {
168 final[i] = paragraphLevel
169 resetting = true
170 } else if (kinds[i] === 1 && resetting) {
171 final[i] = paragraphLevel
172 } else {
173 resetting = false
174 }
175 }
176 const order = final.map((_, i) => i)
177 let max = 0
178 let minOdd = Infinity
179 for (const l of final) {
180 if (l > max) max = l
181 if (l & 1 && l < minOdd) minOdd = l
182 }
183 for (let level = max; level >= minOdd; level--) {
184 for (let i = 0; i < order.length; i++) {
185 if (final[order[i]] < level) continue
186 let j = i
187 while (j + 1 < order.length && final[order[j + 1]] >= level) j++
188 for (let a = i, b = j; a < b; a++, b--) {
189 const t = order[a]
190 order[a] = order[b]
191 order[b] = t
192 }
193 i = j
194 }
195 }
196 return { order, final }
197}
198
199// A cluster's class for span isolation (isolate.ts), over every scalar it holds, since a cluster
200// can start with a Prepend scalar (U+0600 ARABIC NUMBER SIGN + a letter): R when it holds a strong
201// RTL scalar (R, AL), A when it holds an Arabic number (AN), D a European number (EN), O otherwise.
202// A cluster that mixes R with AN is classed R; a browser may still reorder inside it. This is rare
203// enough to accept: it needs U+0600-0605 (a number sign) followed by a letter in one cluster.
204export function charClass(cluster: string): 'R' | 'A' | 'D' | 'O' {
205 let k: 'R' | 'A' | 'D' | 'O' = 'O'
206 for (const ch of cluster) {
207 const t = classOf(ch)
208 if (t === 'R' || t === 'AL') return 'R'
209 if (t === 'AN') k = 'A'
210 else if (t === 'EN' && k === 'O') k = 'D'
211 }
212 return k
213}
214
215// L4 on a cluster: its base scalar mirrored (a bracket carrying a combining mark keeps the mark),
216// or null when the base has no mirror.
217export function mirror(cluster: string): string | null {
218 const cp = cluster.codePointAt(0)
219 if (cp === undefined) return null
220 const base = String.fromCodePoint(cp)
221 const m = bidi.getMirroredCharacter(base) as string | null
222 return m ? m + cluster.slice(base.length) : null
223}
224
225// Whether text holds any strong RTL scalar (R or AL), the test for drawing a block ourselves.
226export function hasRtl(text: string): boolean {
227 for (const ch of text) {
228 const cp = ch.codePointAt(0)!
229 if (cp < 0x0590) continue
230 const t = classOf(ch)
231 if (t === 'R' || t === 'AL') return true
232 }
233 return false
234}
235
236// Claude Code reorders RTL text itself when it takes the terminal for one that cannot: `WT_SESSION`
237// set, or `TERM_PROGRAM=vscode` (2.1.289's `isNeeded()`). Measured on 2.1.289: each Text it draws is
238// reordered on its own, in place (a Text's nested Texts with it, sibling Texts apart), by UAX #9
239// with a first-strong base and no mirroring. This is that reorder, on grapheme clusters.
240export function engineReorder(text: string): string {
241 const clusters = graphemes(text)
242 const { level, levels } = resolveParagraph(text, null, 'first-strong', 0.3, true)
243 const lv: number[] = []
244 const kinds: number[] = []
245 let at = 0
246 for (const c of clusters) {
247 lv.push(levels[at] ?? level)
248 kinds.push(l1Kind(c))
249 at += c.length
250 }
251 return reorderLine(lv, kinds, level).order.map(i => clusters[i]).join('')
252}
253
254// Whether the engine's reorder can move anything in `text`: it holds a strong RTL scalar or an
255// Arabic number (which an LTR run reverses too).
256function reorderable(text: string): boolean {
257 for (const ch of text) {
258 if (ch.codePointAt(0)! < 0x0590) continue
259 const t = classOf(ch)
260 if (t === 'R' || t === 'AL' || t === 'AN') return true
261 }
262 return false
263}
264
265// A text in visual order, cut into the Texts to draw so that the engine's reorder above gives it
266// back: runs of one direction, each holding what that reorder turns into the run. A run of one
267// direction is its own inverse under the reorder (RTL letters reversed, numbers kept), which is
268// checked; a run that is not is drawn a cluster per Text, which nothing reorders.
269export function engineSegments(text: string): string[] {
270 if (!reorderable(text)) return [text]
271 const runs: string[] = []
272 let run = ''
273 let side: 'L' | 'R' | null = null
274 for (const ch of text) {
275 const t = ch.codePointAt(0)! < 0x80 ? (/[A-Za-z]/.test(ch) ? 'L' : 'N') : classOf(ch)
276 const now = t === 'L' ? 'L' : t === 'R' || t === 'AL' || t === 'AN' ? 'R' : null
277 if (now !== null && side !== null && now !== side) {
278 runs.push(run)
279 run = ''
280 }
281 if (now !== null) side = now
282 run += ch
283 }
284 if (run) runs.push(run)
285 const out: string[] = []
286 for (const r of runs) {
287 if (!reorderable(r)) {
288 out.push(r)
289 continue
290 }
291 const drawn = engineReorder(r)
292 if (engineReorder(drawn) === r) out.push(drawn)
293 else out.push(...graphemes(r))
294 }
295 return out
296}
297src/code.ts 686 lines1// Fenced code that holds RTL text. Claude Code's `Code` element writes each line in logical order,
2// which a terminal without bidi shows letter-reversed, and a browser terminal draws every
3// highlighted token as a span of its own and reverses each one again. So a line with RTL text is
4// drawn here instead: an LTR line, left-aligned, as a bidi-aware editor shows code.
5//
6// The line is cut into tokens (comments, strings, plain code) by a small scanner per language
7// family. A comment's text, a string's content, and a run of RTL words in plain code are each laid
8// out as a paragraph of their own (UAX #9 with the reply's direction setting), like text between
9// FSI and PDI; everything else keeps its place. Without that isolation the algorithm would mix the
10// two sides of a quote or a comma: `x = "שלום" # הערה` would read `x = "הרעה # "םולש`.
11//
12// The scanner is conservative: what it cannot place as a string or comment stays code, in place.
13// A quote with no closing quote on its line is a plain character (a Rust lifetime, a Hebrew
14// gershayim in HTML text); a Rust or C `'` opens a character literal only; a JavaScript `/` that
15// starts a regular expression takes it whole; HTML quotes count inside a tag only. Inside a string,
16// an escape (`\"`) and an interpolation (`${…}`, an f-string's `{…}`) are atoms: drawn as code, in
17// their own order (an interpolation's RTL pieces laid out like any code), and kept whole as LTR
18// pieces of the string's paragraph.
19//
20// Lines without RTL text stay with the engine's `Code`, so English-only fences and the English
21// lines of a fence look exactly as usual. A line inside a multi-line comment or string that follows
22// a line drawn here is drawn here too: a `Code` leaf starting there would not know it is inside one.
23// The mod has no highlighter: its lines color comments and strings only, in the colors Claude
24// Code's highlighter gives them, and draw other tokens in the theme's text color.
25
26import { resolveParagraph, hasRtl, type Base } from './bidi.ts'
27import { breakLines, cleanText, clustersOf, layoutParagraph, type Arabic, type Run, type Span } from './layout.ts'
28import { clusterWidth, graphemes } from './width.ts'
29
30// Style flags of a code token, next to markdown.ts's (BOLD = 1 ... DIM = 64) and render.ts's
31// BULLET = 128.
32export const COMMENT = 256
33export const STRING = 512
34
35// Tabs as Claude Code's `Code` element draws them (2.1.289, measured in a mod's tree): each tab of
36// a line's leading indentation as 2 spaces, any later tab to the next multiple of 8 from the start
37// of the line. The mod's code lines follow it, so a fence keeps one indentation.
38export const CODE_TAB = 8
39const leadingTabs = (line: string) => line.replace(/^[ \t]+/, m => m.replace(/\t/g, ' '))
40
41// `iso`: laid out as a paragraph of its own when it holds RTL text.
42type Tok = { text: string; style: number; iso: boolean }
43
44// An interpolation inside a string: its opening and closing delimiters.
45type Interp = { open: string; close: string }
46
47// Inside a block comment or a multi-line string, until `close`.
48type State = { close: string; style: number; interps: Interp[] } | null
49
50type Lang = {
51 // one paragraph per line: text formats and output
52 prose: boolean
53 line: string[]
54 block: [string, string][]
55 quotes: string
56 multi: string[]
57 // data formats (YAML, TOML, HTML text): a run of RTL words carries on across punctuation, quote
58 // marks inside words (`צה"ל`) and numbers, as in a sentence; in program code only across spaces,
59 // so that `f(שם, גיל)` keeps its arguments in order.
60 wide: boolean
61 // `'` opens a character literal only (`'a'`, `'\n'`): Rust lifetimes and C-family code
62 chars: boolean
63 // where `#` starts a comment outside a string: `any` place; at a `word` start only (after a space:
64 // YAML scalars); at a word start or after a `shell` separator (`;#`, `)#`, but `$#`, `a#b`, `a{#`);
65 // after those or a brace (`ps`: PowerShell braces are tokens); anywhere but after `$` (`perl`:
66 // `$#items` is an index); where a command starts only (`tcl`: line start or after `;`, so
67 // `set c #fff` is an argument); `make`: anywhere, but by the shell rule on a recipe line
68 hash: 'any' | 'word' | 'shell' | 'ps' | 'perl' | 'tcl' | 'make'
69 // which lines with RTL letters this scanner can place, given the whole fence (`Sure`); the others
70 // leave the fence to the engine
71 sure: Sure | null
72 // JavaScript regular expression literals
73 regex: boolean
74 // markup: quotes are strings inside a tag only
75 tags: boolean
76 // the interpolations of a string opened by `quote`, given the letters just before it (`f`, `$`)
77 interp: (quote: string, prefix: string) => Interp[]
78}
79
80const none = () => []
81const lang = (o: Partial<Lang>): Lang => ({ prose: false, line: [], block: [], quotes: `"'\``, multi: [], wide: false, chars: false, hash: 'word', sure: null, regex: false, tags: false, interp: none, ...o })
82
83// Perl, Tcl and Make: where `#` starts a comment depends on the language's own parse (Perl's
84// regexes, quote-like operators, interpolations, heredocs and POD; Tcl's braces, quotes and
85// escapes; Make's expansions, recipes and continued lines), which this scanner does not follow. In
86// a fence where none of those constructs appears, every `#` is placed for sure and the fence is
87// drawn as any other; where one does, a line with RTL letters leaves the whole fence to the engine,
88// a line that starts with `#` too (it may sit inside a literal, or close one and go on as code).
89type Sure = (lines: string[]) => (line: string) => boolean
90const WHOLE_COMMENT = /^\s*#/
91// a closed string; only a double-quoted one interpolates (`"${name}"`, `"@list"`)
92const PERL_STRING = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g
93// a quote-like operator, before any `#` is taken as a comment: `q#...#` uses `#` as its delimiter
94const PERL_OP = /(?<![\w$@%&])(?:qr|qq|qw|q|m|s|tr|y)\s*[^\w\s]/
95// in the code before the comment: a regex or a division, a backtick, a heredoc, a quote that does
96// not close on its line, POD, the data section
97const PERL_RISK = /[/`"']|<<|^=[A-Za-z]|^__(?:END|DATA)__\b/
98const perlSure: Sure = lines => {
99 const risky = lines.some(line => {
100 if (WHOLE_COMMENT.test(line)) return false
101 let interpolates = false
102 const plain = line.replace(PERL_STRING, q => {
103 if (q[0] === '"' && /[$@]/.test(q)) interpolates = true
104 return '0'
105 })
106 return interpolates || PERL_OP.test(plain) || PERL_RISK.test(plain.replace(/(?<!\$)#.*$/, ''))
107 })
108 return () => !risky
109}
110// Tcl: braces and quotes group words over lines and hide `;`, and a backslash escapes it
111const tclSure: Sure = lines => {
112 // a comment that ends in a backslash goes on into the next line, in Tcl as in Make
113 const risky = lines.some(line => (WHOLE_COMMENT.test(line) ? /\\$/.test(line) : /[\\"{}[\]]/.test(line)))
114 return () => !risky
115}
116// The shell's quotes on a recipe line, read in order: outside single quotes (inside double quotes
117// too) a backslash escapes (`\;#` is no comment) and a backtick runs a command; a quote character
118// inside the other kind of quote is a letter; a quote left open is a string over lines.
119function shellQuotesSure(line: string): boolean {
120 let quote = ''
121 for (const ch of line) {
122 if (quote === "'") {
123 if (ch === "'") quote = ''
124 } else if (ch === '`' || ch === '\\') return false
125 else if (quote === '"') {
126 if (ch === '"') quote = ''
127 } else if (ch === '"' || ch === "'") quote = ch
128 }
129 return quote === ''
130}
131// Make: no quotes of its own, and `#` is a comment anywhere but after a backslash or inside an
132// expansion (`$(subst #,x,...)`), which a recipe line (a tab first) holds as well before the shell
133// reads it by the shell rule. Not sure: an expansion, a backslash, an inline recipe's `;`, a quote
134// outside a recipe, a recipe quote that stays open (a string over lines), a line indented with
135// spaces (a recipe whose tab Markdown expanded), a custom recipe prefix, `.ONESHELL`, a `define`, a
136// continued line.
137const makeSure: Sure = lines => {
138 const risky = lines.some(line => {
139 if (/\.RECIPEPREFIX|\.ONESHELL|^\s*define\b|\\$|^ /.test(line)) return true
140 if (WHOLE_COMMENT.test(line)) return false
141 if (line.startsWith('\t')) {
142 // Make expands `$(...)` inside the shell's quotes too: looked for before the quotes are read
143 if (/\$[({]/.test(line)) return true
144 return !shellQuotesSure(line)
145 }
146 return /[\\;"'`]|\$[({]/.test(line.replace(/#.*$/, ''))
147 })
148 return () => !risky
149}
150
151const DOLLAR: Interp = { open: '${', close: '}' }
152const FAMILIES: [string, Lang][] = [
153 ['text txt plain plaintext md markdown output log csv tsv', lang({ prose: true })],
154 ['python py', lang({ line: ['#'], hash: 'any', multi: ['"""', "'''"], interp: (_q, p) => (/[fF]/.test(p) ? [{ open: '{', close: '}' }] : []) })],
155 ['sh bash zsh shell fish console shell-session', lang({ line: ['#'], hash: 'shell', interp: q => (q === '"' ? [DOLLAR, { open: '$(', close: ')' }] : []) })],
156 ['ruby rb elixir ex exs coffee crystal', lang({ line: ['#'], hash: 'any', interp: q => (q === '"' ? [{ open: '#{', close: '}' }] : []) })],
157 ['r nim julia jl nix graphql gql awk cmake', lang({ line: ['#'], hash: 'any' })],
158 ['make makefile mk', lang({ line: ['#'], hash: 'make', sure: makeSure })],
159 ['powershell ps1', lang({ line: ['#'], hash: 'ps' })],
160 ['tcl', lang({ line: ['#'], hash: 'tcl', sure: tclSure })],
161 ['perl pl', lang({ line: ['#'], hash: 'perl', sure: perlSure })],
162 ['dockerfile', lang({ line: ['#'] })],
163 ['yaml yml env dotenv', lang({ line: ['#'], multi: ['"""', "'''"], wide: true })],
164 ['toml', lang({ line: ['#'], hash: 'any', multi: ['"""', "'''"], wide: true })],
165 ['ini conf properties', lang({ line: ['#', ';'], wide: true })],
166 ['c cpp c++ cc h hpp java go rust rs zig sol solidity objc objective-c m mm proto protobuf glsl hlsl wgsl verilog sv', lang({ line: ['//'], block: [['/*', '*/']], chars: true })],
167 ['cs csharp', lang({ line: ['//'], block: [['/*', '*/']], chars: true, interp: (q, p) => (q === '"' && p.includes('$') ? [{ open: '{', close: '}' }] : []) })],
168 ['kotlin kt kts scala', lang({ line: ['//'], block: [['/*', '*/']], multi: ['"""'], chars: true, interp: q => (q !== "'" ? [DOLLAR] : []) })],
169 ['swift', lang({ line: ['//'], block: [['/*', '*/']], multi: ['"""'], quotes: '"', interp: () => [{ open: '\\(', close: ')' }] })],
170 ['dart groovy', lang({ line: ['//'], block: [['/*', '*/']], multi: ['"""', "'''"], interp: () => [DOLLAR] })],
171 ['json json5 jsonc scss less', lang({ line: ['//'], block: [['/*', '*/']] })],
172 ['js javascript jsx mjs cjs ts typescript tsx mts cts', lang({ line: ['//'], block: [['/*', '*/']], multi: ['`'], regex: true, interp: q => (q === '`' ? [DOLLAR] : []) })],
173 ['php', lang({ line: ['//', '#'], hash: 'any', block: [['/*', '*/']], interp: q => (q === '"' ? [{ open: '{$', close: '}' }, DOLLAR] : []) })],
174 ['hcl tf terraform', lang({ line: ['//', '#'], hash: 'any', block: [['/*', '*/']], interp: q => (q === '"' ? [DOLLAR] : []) })],
175 ['css', lang({ block: [['/*', '*/']] })],
176 ['sql mysql postgres postgresql psql plsql sqlite tsql', lang({ line: ['--'], block: [['/*', '*/']], quotes: `"'` })],
177 ['lua', lang({ line: ['--'], block: [['--[[', ']]']] })],
178 ['haskell hs elm ada vhdl applescript', lang({ line: ['--'], quotes: '"' })],
179 ['html htm xml svg xhtml vue svelte xaml plist', lang({ block: [['<!--', '-->']], quotes: `"'`, wide: true, tags: true })],
180 ['lisp clojure clj cljs scheme scm racket elisp emacs-lisp asm nasm', lang({ line: [';'], quotes: '"' })],
181 ['tex latex matlab octave erlang erl prolog', lang({ line: ['%'], quotes: '"' })],
182]
183const LANGS = new Map<string, Lang>()
184for (const [names, l] of FAMILIES) for (const n of names.split(' ')) LANGS.set(n, l)
185// An untagged fence is most often text or output; an unknown tag, some program.
186const UNKNOWN = lang({ line: ['#', '//'], block: [['/*', '*/']] })
187const langOf = (name: string): Lang => LANGS.get(name) ?? (name ? UNKNOWN : LANGS.get('text')!)
188
189// The first `close` at or after `from` outside a backslash escape, or -1.
190function findClose(line: string, from: number, close: string): number {
191 for (let k = from; k < line.length; k++) {
192 if (line[k] === '\\') k++
193 else if (line.startsWith(close, k)) return k
194 }
195 return -1
196}
197
198// Atoms stand in the text as private-use characters of plane 16, one per grapheme of the atom, with
199// that grapheme's width: the line is measured and wrapped with them (a wide grapheme stays whole), an
200// isolate's layout keeps them in order as an LTR isolate, and they are replaced by the atom's spans
201// at the end. A line that holds such characters itself gets no atoms, and its own characters pass
202// through untouched. A line with more atoms than placeholders is not drawn (`full`).
203const ATOM_BASE = 0x100000
204const ATOM_MAX = 0xfffd
205// a placeholder with the marks that combine with it in the source (they stay in its grapheme)
206const ATOMS = /(?:[\u{100000}-\u{10FFFF}][\p{M}\u200d]*)+/gu
207const ATOM = /[\u{100000}-\u{10FFFF}]/gu
208type Cell = Span & { width: number }
209// `prev`: the significant code of the lines before (strings as `0`), the context a regex needs.
210// `unsure`: a construct this scanner cannot follow over lines (an interpolation still open at the
211// line's end): the whole fence is left to the engine. `recipe`: a Make recipe line (a tab first).
212type Ctx = { cells: Cell[]; ok: boolean; full: boolean; unsure: boolean; recipe: boolean; prev: string; l: Lang; opts: CodeOptions }
213
214function atom(ctx: Ctx, spans: Span[]): string {
215 let out = ''
216 for (const sp of spans) {
217 for (const g of graphemes(sp.text)) {
218 if (ctx.cells.length > ATOM_MAX) {
219 ctx.full = true
220 return out
221 }
222 out += String.fromCodePoint(ATOM_BASE + ctx.cells.length)
223 ctx.cells.push({ text: g, style: sp.style, width: clusterWidth(g) })
224 }
225 }
226 return out
227}
228
229// Spans with their atoms put back.
230function fill(spans: Span[], ctx: Ctx): Span[] {
231 const out: Span[] = []
232 const push = (text: string, style: number) => {
233 const prev = out[out.length - 1]
234 if (prev && prev.style === style) prev.text += text
235 else if (text) out.push({ text, style })
236 }
237 for (const sp of spans) {
238 for (const ch of sp.text) {
239 const cp = ch.codePointAt(0)!
240 const cell = atomCell(cp, ctx)
241 if (cell) push(cell.text, cell.style)
242 else push(ch, sp.style)
243 }
244 }
245 return out
246}
247
248// The atom a character stands for: only on a line that made atoms, so a source character never is one.
249const atomCell = (cp: number, ctx: Ctx): Cell | undefined => (ctx.ok && cp >= ATOM_BASE ? ctx.cells[cp - ATOM_BASE] : undefined)
250const stripAtoms = (text: string, ctx: Ctx) => (ctx.ok ? text.replace(ATOMS, '') : text)
251
252const ESCAPE = /^\\(?:u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2}|N\{[^}]*\}|[0-7]{1,3}|.)/su
253
254// The index of the delimiter that closes an interpolation opened before `from`, skipping nested
255// brackets and quoted strings; -1 when it does not close on this line, -2 when its end is uncertain
256// (a `/` that may open a comment or a regex holding the delimiter).
257function interpEnd(line: string, from: number, ip: Interp, l: Lang): number {
258 const opener = ip.open[ip.open.length - 1]
259 let depth = 1
260 for (let k = from; k < line.length; k++) {
261 const c = line[k]
262 if (c === '\\') k++
263 else if (c === '"' || c === "'" || c === '`') {
264 const j = findClose(line, k + 1, c)
265 if (j < 0) return -1
266 k = j
267 } else if (c === '/' && (l.regex || line[k + 1] === '*' || line[k + 1] === '/')) return -2
268 else if (c === opener) depth++
269 else if (c === ip.close && --depth === 0) return k
270 }
271 return -1
272}
273
274const WORD = /[\p{L}\p{M}\p{N}_]+/gu
275const NUMBER = /^\p{N}+$/u
276
277// Plain code: a run of words that hold RTL text is one isolate; the rest keeps its place.
278function plainToks(text: string, wide: boolean): Tok[] {
279 const parts: { text: string; word: boolean }[] = []
280 let at = 0
281 for (const m of text.matchAll(WORD)) {
282 if (m.index > at) parts.push({ text: text.slice(at, m.index), word: false })
283 parts.push({ text: m[0], word: true })
284 at = m.index + m[0].length
285 }
286 if (at < text.length) parts.push({ text: text.slice(at), word: false })
287 const joins = wide ? /^[\s,.:!?\-–—'"׳״]+$/u : /^\s+$/u
288 const rtlWord = (p: { text: string; word: boolean }) => p.word && hasRtl(p.text)
289 const out: Tok[] = []
290 for (let i = 0; i < parts.length; ) {
291 if (!rtlWord(parts[i])) {
292 out.push({ text: parts[i].text, style: 0, iso: false })
293 i++
294 continue
295 }
296 let end = i
297 while (end + 2 < parts.length && joins.test(parts[end + 1].text)) {
298 const next = parts[end + 2]
299 if (!(rtlWord(next) || (wide && next.word && NUMBER.test(next.text)))) break
300 end += 2
301 }
302 let text = parts.slice(i, end + 1).map(p => p.text).join('')
303 // a sentence's closing punctuation goes with it
304 const close = wide && end + 1 < parts.length ? parts[end + 1].text.match(/^[.,:;!?…]+/) : null
305 if (close) {
306 text += close[0]
307 parts[end + 1] = { text: parts[end + 1].text.slice(close[0].length), word: false }
308 }
309 out.push({ text, style: 0, iso: true })
310 i = end + 1
311 if (close && !parts[i].text) i++
312 }
313 return out
314}
315
316// A comment's text or a string's content: its outer spaces keep their place, the rest is isolated.
317function content(text: string, style: number, out: Tok[]): void {
318 const m = text.match(/^(\s*)(.*?)(\s*)$/su)!
319 if (m[1]) out.push({ text: m[1], style, iso: false })
320 if (m[2]) out.push({ text: m[2], style, iso: true })
321 if (m[3]) out.push({ text: m[3], style, iso: false })
322}
323
324// A string's content from `from` up to `close`, its escapes and interpolations as atoms; `end` is
325// the index of the close, or -1 when the line ends first.
326function stringBody(line: string, from: number, close: string, interps: Interp[], ctx: Ctx): { text: string; end: number } {
327 let text = ''
328 let k = from
329 while (k < line.length) {
330 if (line.startsWith(close, k)) return { text, end: k }
331 const ip = interps.find(p => line.startsWith(p.open, k))
332 if (ip) {
333 // a doubled brace is a literal brace in f-strings and C#
334 if (ip.open === '{' && line[k + 1] === '{') {
335 text += '{{'
336 k += 2
337 continue
338 }
339 const e = interpEnd(line, k + ip.open.length, ip, ctx.l)
340 // an interpolation that cannot be kept whole: the line is not drawn; one that goes on past the
341 // line: nothing of the fence is
342 if (e === -2 || (e >= 0 && !ctx.ok)) ctx.full = true
343 if (e === -1) ctx.unsure = true
344 if (e >= 0 && ctx.ok) {
345 const expr = codeSpans(line.slice(k + ip.open.length, e), ctx)
346 text += atom(ctx, [{ text: ip.open, style: 0 }, ...expr, { text: ip.close, style: 0 }])
347 k = e + ip.close.length
348 continue
349 }
350 }
351 if (line[k] === '\\') {
352 const esc = line.slice(k).match(ESCAPE)?.[0] ?? '\\'
353 if (!ctx.ok) ctx.full = true
354 text += ctx.ok ? atom(ctx, [{ text: esc, style: STRING }]) : esc
355 k += esc.length
356 continue
357 }
358 const ch = String.fromCodePoint(line.codePointAt(k)!)
359 text += ch
360 k += ch.length
361 }
362 return { text, end: -1 }
363}
364
365// An interpolated expression as drawn: scanned and laid out as a code line of its own.
366function codeSpans(expr: string, ctx: Ctx): Span[] {
367 // an interpolated expression starts a context of its own
368 const prev = ctx.prev
369 ctx.prev = ''
370 const toks = scanLine(expr, null, ctx).toks
371 ctx.prev = prev
372 return layoutLine(toks, UNBOUNDED, ctx)?.[0] ?? [{ text: expr, style: 0 }]
373}
374
375const REGEX_BEFORE = /(?:^|[(,=:[!&|?{};+\-*%<>~^]|\b(?:return|typeof|case|do|else|in|of|void|yield|await|delete|throw|new))\s*$/
376// Whether a `/` after `before` opens a regular expression: after an operator or keyword, or after the
377// `)` that closes an `if`/`while`/`for`/`with` header (a statement starts there; `f(x) / 2` divides).
378// `before` is significant code only (strings, regexes and comments replaced), so its brackets count.
379function regexCan(before: string): boolean {
380 const t = before.trimEnd()
381 // `x++ / 2` divides
382 if (/(?:\+\+|--)$/.test(t)) return false
383 if (REGEX_BEFORE.test(before)) return true
384 if (!t.endsWith(')')) return false
385 let depth = 0
386 for (let k = t.length - 1; k >= 0; k--) {
387 if (t[k] === ')') depth++
388 else if (t[k] === '(' && --depth === 0) return /(?:^|[^\w$.])(?:if|while|for|with)\s*$/.test(t.slice(0, k))
389 }
390 return false
391}
392// The character before a `#` that starts a comment, per `Lang.hash`.
393const SHELL_HASH = /[\s;|&()]/
394function hashStarts(line: string, i: number, ctx: Ctx): boolean {
395 if (i === 0) return true
396 const before = line[i - 1]
397 switch (ctx.l.hash) {
398 case 'any':
399 return true
400 case 'word':
401 return /\s/.test(before)
402 case 'shell':
403 return SHELL_HASH.test(before)
404 case 'ps':
405 return /[\s;|&(){}]/.test(before)
406 case 'perl':
407 return before !== '$'
408 case 'tcl':
409 return /(?:^|;)\s*$/.test(line.slice(0, i))
410 case 'make':
411 return !ctx.recipe || SHELL_HASH.test(before)
412 }
413}
414
415// Significant code kept for `regexCan`: spaces folded, closed bracket groups emptied (only what stands
416// before a `(` matters once it closes). A context this cannot keep short is `LOST`: no regex decision.
417const LOST = '\u0000'
418function compact(code: string): string {
419 if (code.startsWith(LOST)) return LOST
420 let s = code.replace(/\s+/g, ' ')
421 for (let t = ''; t !== s; ) {
422 t = s
423 s = s.replace(/\((?!0\))[^()]*\)/g, '(0)')
424 }
425 return s.length > 20000 ? LOST : s
426}
427const CHAR = /^'(?:\\(?:u\{[0-9a-fA-F]+\}|x[0-9a-fA-F]{2}|.)|[^'\\\n])'/su
428
429// One line's tokens, and the state the next line starts in.
430function scanLine(line: string, state: State, ctx: Ctx): { toks: Tok[]; state: State; sig: string } {
431 const l = ctx.l
432 const out: Tok[] = []
433 if (l.prose) {
434 content(line, 0, out)
435 return { toks: out, state: null, sig: '' }
436 }
437 let i = 0
438 let plain = ''
439 // the line's significant code so far: strings and regexes as `0`, comments as a space
440 let sig = ''
441 let inTag = false
442 const flush = () => {
443 if (plain) out.push(...plainToks(plain, l.wide))
444 plain = ''
445 }
446 // The rest of a block comment from `i`, up to its close or the end of the line.
447 const comment = (close: string, lineStart: boolean): State => {
448 const j = line.indexOf(close, i)
449 let text = line.slice(i, j < 0 ? line.length : j)
450 // a continued block comment's leading ` * ` is a delimiter, not text
451 const star = lineStart ? text.match(/^\s*\*(?!\/)\s*/) : null
452 if (star) {
453 out.push({ text: star[0], style: COMMENT, iso: false })
454 text = text.slice(star[0].length)
455 }
456 content(text, COMMENT, out)
457 sig += ' '
458 if (j < 0) {
459 i = line.length
460 return { close, style: COMMENT, interps: [] }
461 }
462 out.push({ text: close, style: COMMENT, iso: false })
463 i = j + close.length
464 return null
465 }
466 // A string's content from `i`; null when it closes on this line.
467 const string = (close: string, interps: Interp[], multi: boolean): State | 'open' => {
468 const body = stringBody(line, i, close, interps, ctx)
469 if (body.end < 0 && !multi) return 'open'
470 content(body.text, STRING, out)
471 if (body.end < 0) {
472 i = line.length
473 return { close, style: STRING, interps }
474 }
475 out.push({ text: close, style: STRING, iso: false })
476 i = body.end + close.length
477 return null
478 }
479 if (state) {
480 const next = state.style === COMMENT ? comment(state.close, true) : (string(state.close, state.interps, true) as State)
481 if (next) return { toks: out, state: next, sig }
482 }
483 scan: while (i < line.length) {
484 for (const [open, close] of l.block) {
485 if (line.startsWith(open, i)) {
486 flush()
487 out.push({ text: open, style: COMMENT, iso: false })
488 i += open.length
489 const next = comment(close, false)
490 if (next) return { toks: out, state: next, sig }
491 continue scan
492 }
493 }
494 for (const mark of l.line) {
495 if (!line.startsWith(mark, i)) continue
496 if (mark === '#' && !hashStarts(line, i, ctx)) continue
497 flush()
498 let k = i + mark.length
499 while (k < line.length && line[k] === mark[mark.length - 1]) k++
500 if (line[k] === '!') k++
501 while (k < line.length && /\s/.test(line[k])) k++
502 out.push({ text: line.slice(i, k), style: COMMENT, iso: false })
503 content(line.slice(k), COMMENT, out)
504 return { toks: out, state: null, sig }
505 }
506 const c = line[i]
507 if (l.tags && !inTag && c === '<' && /[A-Za-z/!?]/.test(line[i + 1] ?? '')) inTag = true
508 else if (l.tags && inTag && c === '>') inTag = false
509 // a `/` whose context was lost cannot be told apart: the line is not drawn
510 if (l.regex && c === '/' && ctx.prev === LOST) ctx.full = true
511 if (l.regex && c === '/' && ctx.prev !== LOST && regexCan(ctx.prev + sig)) {
512 const j = regexEnd(line, i + 1)
513 if (j > 0) {
514 flush()
515 const end = j + 1 + line.slice(j + 1).match(/^[a-z]*/)![0].length
516 out.push({ text: line.slice(i, end), style: 0, iso: false })
517 sig += '0'
518 i = end
519 continue
520 }
521 }
522 const prefix = plain.match(/[A-Za-z$@]*$/)![0]
523 for (const d of l.multi) {
524 if (line.startsWith(d, i)) {
525 flush()
526 out.push({ text: d, style: STRING, iso: false })
527 sig += '0'
528 i += d.length
529 const next = string(d, l.interp(d[0], prefix), true) as State
530 if (next) return { toks: out, state: next, sig }
531 continue scan
532 }
533 }
534 if (l.quotes.includes(c) && (!l.tags || inTag)) {
535 if (c === "'" && l.chars) {
536 const m = line.slice(i).match(CHAR)
537 if (m) {
538 flush()
539 out.push({ text: m[0], style: STRING, iso: false })
540 sig += '0'
541 i += m[0].length
542 continue
543 }
544 } else {
545 const mark = out.length
546 const before = plain
547 flush()
548 out.push({ text: c, style: STRING, iso: false })
549 sig += '0'
550 i++
551 if (string(c, l.interp(c, prefix), false) !== 'open') continue
552 // no closing quote on this line: the quote is a plain character
553 out.length = mark
554 sig = sig.slice(0, -1)
555 plain = before
556 i--
557 }
558 }
559 plain += c
560 sig += c
561 i++
562 }
563 flush()
564 return { toks: out, state: null, sig }
565}
566
567// The index of the `/` that ends a regular expression literal starting at `from`, or -1.
568function regexEnd(line: string, from: number): number {
569 let cls = false
570 for (let k = from; k < line.length; k++) {
571 const c = line[k]
572 if (c === '\\') k++
573 else if (cls) cls = c !== ']'
574 else if (c === '[') cls = true
575 else if (c === '/') return k > from ? k : -1
576 }
577 return -1
578}
579
580export type CodeOptions = { base: Base; share: number; arabic?: Arabic }
581
582// A fence's lines in order: groups the engine's `Code` draws, and rows (visual spans) drawn here.
583export type CodePart = { kind: 'native'; text: string } | { kind: 'rows'; rows: Span[][] }
584
585// Null when a cluster is wider than `room`, or a line is not one the scanner can place for sure (an
586// interpolation still open at its end, a line `Lang.sure` refuses): the engine draws the fence.
587export function codeParts(raw: string, language: string, room: number, opts: CodeOptions): CodePart[] | null {
588 const l = langOf(language)
589 const parts: CodePart[] = []
590 let state: State = null
591 let mine = false
592 let prev = ''
593 const lines = cleanText(raw).split('\n')
594 const sure = l.sure?.(lines)
595 for (const line of lines) {
596 const inside = state !== null
597 const ctx: Ctx = { cells: [], ok: !/[\u{100000}-\u{10FFFF}]/u.test(line), full: false, unsure: false, recipe: line.startsWith('\t'), prev, l, opts }
598 const scanned = scanLine(leadingTabs(line), state, ctx)
599 if (ctx.unsure) return null
600 state = scanned.state
601 prev = compact(prev + scanned.sig + '\n')
602 mine = hasRtl(line) || (inside && mine)
603 const last = parts[parts.length - 1]
604 if (!mine) {
605 if (last?.kind === 'native') last.text += '\n' + line
606 else parts.push({ kind: 'native', text: line })
607 continue
608 }
609 if (sure && !sure(line)) return null
610 const rows = ctx.full ? null : layoutLine(scanned.toks, room, ctx)
611 if (rows === null) return null
612 if (last?.kind === 'rows') last.rows.push(...rows)
613 else parts.push({ kind: 'rows', rows })
614 }
615 return parts
616}
617
618// The row was cut already: an isolate's piece is laid out on one line.
619const UNBOUNDED = 1_000_000
620
621// One code line as rows of at most `room` cells: wrapped as prose is (layout.ts breakLines, on the
622// logical line, tabs at CODE_TAB), then each row's piece of an isolate reordered on its own, in the
623// direction of the whole isolate, its atoms kept whole as LTR isolates.
624function layoutLine(toks: Tok[], room: number, ctx: Ctx): Span[][] | null {
625 const { opts } = ctx
626 const text = toks.map(t => t.text).join('')
627 // a grapheme that holds a placeholder is measured as drawn: the atom's grapheme and any marks after it
628 const cs = clustersOf(text, CODE_TAB, g => (atomCell(g.codePointAt(0)!, ctx) ? clusterWidth(g.replace(ATOM, a => atomCell(a.codePointAt(0)!, ctx)?.text ?? a)) : undefined))
629 const lines = breakLines(cs, room, room)
630 if (lines === null) return null
631 const tokAt = new Int32Array(text.length)
632 let at = 0
633 toks.forEach((t, n) => {
634 tokAt.fill(n, at, at + t.text.length)
635 at += t.text.length
636 })
637 const dirs = toks.map(t => {
638 const words = stripAtoms(t.text, ctx)
639 return t.iso && hasRtl(words) ? (resolveParagraph(words, null, opts.base, opts.share).level & 1 ? 'rtl' : 'ltr') : null
640 })
641 const rows: Span[][] = []
642 for (const [first, last] of lines) {
643 const spans: Span[] = []
644 const push = (sp: Span) => {
645 const prev = spans[spans.length - 1]
646 if (prev && prev.style === sp.style) prev.text += sp.text
647 else if (sp.text) spans.push({ ...sp })
648 }
649 for (let k = first; k < last; ) {
650 const n = tokAt[cs[k].start]
651 let piece = ''
652 for (; k < last && tokAt[cs[k].start] === n; k++) {
653 const c = cs[k]
654 // a tab as the spaces it advances
655 piece += c.text === '\t' ? ' '.repeat(c.width) : c.text
656 }
657 const t = toks[n]
658 const dir = dirs[n]
659 if (dir === null || (dir === 'ltr' && !hasRtl(stripAtoms(piece, ctx)))) {
660 // undrawn format characters are dropped, as in prose
661 push({ text: clustersOf(piece).filter(c => c.drawn).map(c => c.text).join(''), style: t.style })
662 continue
663 }
664 const m = piece.match(/^(\s*)(.*?)(\s*)$/su)!
665 push({ text: m[1], style: t.style })
666 const runs: Run[] = []
667 let from = 0
668 for (const a of ctx.ok ? m[2].matchAll(ATOMS) : []) {
669 if (a.index > from) runs.push({ text: m[2].slice(from, a.index), style: t.style })
670 runs.push({ text: a[0], style: t.style, isolate: true })
671 from = a.index + a[0].length
672 }
673 if (from < m[2].length) runs.push({ text: m[2].slice(from), style: t.style })
674 const laid = layoutParagraph(runs, { width: UNBOUNDED, base: dir, share: opts.share, mode: 'visual', arabic: opts.arabic })
675 if (laid === null || laid.length !== 1) return null
676 for (const sp of laid[0].spans) push(sp)
677 push({ text: m[3], style: t.style })
678 }
679 const row = fill(spans, ctx)
680 // the row as drawn must fit: anything measured otherwise than drawn falls back
681 if (clustersOf(row.map(sp => sp.text).join('')).reduce((w, c) => w + c.width, 0) > room) return null
682 rows.push(row)
683 }
684 return rows
685}
686src/isolate.ts 141 lines1// Span isolation, so that one byte stream reads right in a plain terminal and in a browser
2// terminal that runs bidi again on top of the cell grid.
3//
4// xterm.js's DOM renderer (used by browser terminals) draws each run of cells with equal attributes as one
5// `display:inline-block` span, and the browser runs the bidi algorithm inside every span as its
6// own LTR paragraph. Visual-order Hebrew inside one span is reversed a second time there. A span
7// that holds a single strong-RTL cluster has nothing to reorder, though, and a span with no RTL
8// keeps its order. A plain terminal ignores the split and draws the cells as they are.
9//
10// So a visual row starts a new attribute run (`tick` flips) wherever the run so far plus the next
11// cluster would not keep its order under an LTR bidi pass. In an LTR paragraph a strong-RTL letter
12// gets level 1, an Arabic number (AN) level 2, a European number (EN) level 2 after an RTL letter
13// and level 0 otherwise, and a neutral between two of R/AN takes level 1. A run stays in order
14// while it holds at most one RTL letter and no number beside it, or numbers with no RTL letter,
15// where Arabic numbers form one unbroken group (`١-٢` is AN, ES, AN: levels 2 1 2, reversed). So
16// a new run starts:
17// - before an RTL letter when the run holds an RTL letter or a number;
18// - before a European number when the run holds an RTL letter;
19// - before an Arabic number when the run holds an RTL letter, or an Arabic number that is not
20// right before it.
21// A run is what the terminal will merge: consecutive cells whose final attributes are equal. So
22// the state follows the attributes the pieces will really be drawn with (`attr`, after palette,
23// overrides and inheritance), not the style spans: a style change whose attributes come out equal
24// does not start a run, and a forced split flips `tick`, which always changes the attributes of
25// the piece's own style. The two colors of a style differ by one unit per channel: invisible, but
26// distinct. Measured in tools/xterm-harness (xterm.js 6.1.0-beta.304, Chromium 1208, Liberation
27// Mono and DejaVu Sans Mono).
28
29import { charClass } from './bidi.ts'
30import { COMMENT, STRING } from './code.ts'
31import type { Span } from './layout.ts'
32import { BOLD, CODE, DIM, ITALIC, STRIKE, UNDERLINE } from './markdown.ts'
33import { graphemes } from './width.ts'
34
35export type Piece = { text: string; style: number; tick: boolean }
36
37// The final attributes of a piece, as one comparable key.
38export type AttrKey = (style: number, tick: boolean) => string
39
40const abstractKey: AttrKey = (style, tick) => `${style}|${tick}`
41
42// A row's spans (visual order, the whole row: body and prefix) cut into pieces at style changes and
43// at forced boundaries.
44export function isolateRow(spans: Span[], attr: AttrKey = abstractKey): Piece[] {
45 const out: Piece[] = []
46 let tick = false
47 let run: string | null = null
48 let hasRtl = false
49 let hasDigit = false
50 let hasAn = false
51 let prevAn = false
52 const reset = () => {
53 hasRtl = false
54 hasDigit = false
55 hasAn = false
56 }
57 for (const span of spans) {
58 let cur = ''
59 for (const g of graphemes(span.text)) {
60 const key = attr(span.style, tick)
61 if (key !== run) {
62 run = key
63 reset()
64 }
65 const k = charClass(g)
66 const split =
67 (k === 'R' && (hasRtl || hasDigit || hasAn)) || (k === 'D' && hasRtl) || (k === 'A' && (hasRtl || (hasAn && !prevAn)))
68 if (split) {
69 if (cur) out.push({ text: cur, style: span.style, tick })
70 cur = ''
71 tick = !tick
72 run = attr(span.style, tick)
73 reset()
74 }
75 cur += g
76 if (k === 'R') hasRtl = true
77 if (k === 'D') hasDigit = true
78 if (k === 'A') hasAn = true
79 prevAn = k === 'A'
80 }
81 if (cur) out.push({ text: cur, style: span.style, tick })
82 }
83 return out
84}
85
86export function rowHasRtl(spans: Span[]): boolean {
87 return spans.some(s => graphemes(s.text).some(g => charClass(g) === 'R'))
88}
89
90// The theme colors the mod draws with, per Claude Code theme (read from the 2.1.288 binary's theme
91// table): `text` (the reply bullet's color), `permission` (inline code), `userMessageBackground`.
92// Ansi themes have no one-unit neighbour, so they get no ticks (`null`): correct in a terminal,
93// and the documented degradation in a browser terminal.
94export type Palette = { text: string; code: string; userBg: string } | null
95
96const THEMES: Record<string, Palette> = {
97 dark: { text: '#ffffff', code: '#b1b9f9', userBg: '#373737' },
98 'dark-daltonized': { text: '#ffffff', code: '#99ccff', userBg: '#373737' },
99 light: { text: '#000000', code: '#5769f7', userBg: '#f0f0f0' },
100 'light-daltonized': { text: '#000000', code: '#3366ff', userBg: '#dcdcdc' },
101 'dark-ansi': null,
102 'light-ansi': null,
103}
104
105export function paletteFor(theme: string | undefined, textOverride?: string): Palette {
106 const p = theme !== undefined && theme in THEMES ? THEMES[theme] : theme?.startsWith('light') ? THEMES.light : THEMES.dark
107 if (p && textOverride && /^#[0-9a-fA-F]{6}$/.test(textOverride)) return { ...p, text: textOverride.toLowerCase() }
108 return p
109}
110
111// The same color moved one unit per channel toward the middle, so it never clips.
112export function nudge(hex: string): string {
113 const n = parseInt(hex.slice(1), 16)
114 return '#' + [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => (v >= 128 ? v - 1 : v + 1).toString(16).padStart(2, '0')).join('')
115}
116
117// Comments and strings of the code lines drawn by the mod (code.ts), in the colors Claude Code's
118// highlighter gives them, ANSI green and red, as the engine writes those names (2.1.289).
119export const COMMENT_COLOR = '#46a758'
120export const STRING_COLOR = '#e5484d'
121
122// The attributes a piece of a colored row is drawn with: its style, and the color of its kind
123// (a comment, a string, inline code, or text) with the tick applied. Every piece's own color is set, so equal props
124// mean equal cells.
125export type PieceProps = { color: string; bold?: true; italic?: true; strikethrough?: true; underline?: true; dimColor?: true }
126
127export function pieceProps(style: number, tick: boolean, palette: NonNullable<Palette>): PieceProps {
128 const base = style & COMMENT ? COMMENT_COLOR : style & STRING ? STRING_COLOR : style & CODE ? palette.code : palette.text
129 const p: PieceProps = { color: tick ? nudge(base) : base }
130 if (style & BOLD) p.bold = true
131 if (style & ITALIC) p.italic = true
132 if (style & STRIKE) p.strikethrough = true
133 if (style & UNDERLINE) p.underline = true
134 if (style & DIM) p.dimColor = true
135 return p
136}
137
138export function propsKey(p: PieceProps): string {
139 return `${p.color}|${p.bold ? 'b' : ''}${p.italic ? 'i' : ''}${p.strikethrough ? 's' : ''}${p.underline ? 'u' : ''}${p.dimColor ? 'd' : ''}`
140}
141src/layout.ts 230 lines1// Paragraph layout: logical styled runs in, rows of visual styled spans out.
2//
3// Order of work (UAX #9 section 3.4): resolve the paragraph's embedding levels on the whole
4// logical paragraph, break lines on logical text by terminal cell width, then reorder each line
5// on its own (L1, L2 on whole grapheme clusters, L4 mirroring). Styles travel with clusters, so
6// a bold word stays bold wherever it lands. A cluster's style is that of its first code unit.
7//
8// A `\n` inside the runs is a forced line break within the same paragraph (a Markdown soft or
9// hard break, a newline in a prompt): the lines on both sides share one resolution, so a line
10// that starts with an English word still sits on the paragraph's RTL base.
11
12import { l1Kind, mirror, reorderLine, resolveParagraph, type Base } from './bidi.ts'
13import { clusterWidth, graphemes } from './width.ts'
14import { needsShaping, shape } from './shape.ts'
15
16// `isolate`: the run is an LTR isolate for resolution (inline code: a path or a flag keeps its
17// own order inside RTL text).
18export type Run = { text: string; style: number; isolate?: boolean }
19export type Span = { text: string; style: number }
20export type Row = { spans: Span[]; width: number; rtl: boolean }
21export type Mode = 'visual' | 'logical'
22// How Arabic-script letters are written in visual mode (docs/design/arabic-persian.md):
23// `letters`, the plain letters, which a terminal that shapes joins with its font (Windows
24// Terminal); `forms`, the presentation forms of shape.ts, for terminals that cannot join them
25// (browser terminals built on xterm.js, where each RTL letter is its own color run).
26export type Arabic = 'letters' | 'forms'
27
28export type LayoutOptions = {
29 // cells available to the first line, and to every later line (defaults to `width`)
30 width: number
31 restWidth?: number
32 base: Base
33 share?: number
34 mode: Mode
35 // default `letters`
36 arabic?: Arabic
37}
38
39export const TAB_STOP = 4
40
41// Bidi controls take part in level resolution but are never drawn: the engine would show the
42// embedding/isolate ones as U+FFFD and strips the marks. Other format characters standing alone
43// (ZWSP, soft hyphen, BOM) have no glyph and are not drawn either. Inside a cluster (ZWJ, VS16)
44// they stay with it.
45const UNDRAWN = /^\p{Cf}+$/u
46const SPACE = /^[\t - ]$/u
47// C0 and C1 controls other than tab and newline never reach a leaf.
48const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/gu
49const LINE_BREAK = /\r\n?|[\u2028\u2029\u0085]/gu
50
51export type Cluster = { start: number; end: number; text: string; width: number; space: boolean; drawn: boolean; brk: boolean }
52
53export function cleanText(text: string): string {
54 return text.replace(LINE_BREAK, '\n').replace(CONTROL, '')
55}
56
57// Clusters with their cell widths. A tab advances to the next multiple of `tab` (TAB_STOP in prose)
58// counted from the start of its source line (before wrapping), and is drawn as that many spaces.
59// `widthOf` may give a grapheme's width instead (code atoms).
60export function clustersOf(text: string, tab = TAB_STOP, widthOf?: (g: string) => number | undefined): Cluster[] {
61 const out: Cluster[] = []
62 let at = 0
63 let column = 0
64 for (const g of graphemes(text)) {
65 const brk = g === '\n'
66 const drawn = !brk && !UNDRAWN.test(g)
67 let width = drawn ? (widthOf?.(g) ?? clusterWidth(g)) : 0
68 if (g === '\t') width = tab - (column % tab)
69 column = brk ? 0 : column + width
70 out.push({ start: at, end: at + g.length, text: g, width, space: SPACE.test(g), drawn, brk })
71 at += g.length
72 }
73 return out
74}
75
76// Line breaks over clusters: [first, last) cluster ranges, trailing spaces excluded. A soft wrap
77// drops the spaces it consumed from the next line's start; a forced break keeps them. An overlong
78// word is broken at a cluster boundary; a cluster is never split. Returns null when a cluster is
79// wider than its line: that content cannot be laid out at this width.
80export function breakLines(cs: Cluster[], width: number, restWidth: number): [number, number][] | null {
81 const lines: [number, number][] = []
82 const trimEnd = (start: number, end: number) => {
83 while (end > start && cs[end - 1].space) end--
84 return end
85 }
86 let start = 0
87 let forced = true
88 while (start < cs.length) {
89 if (!forced) while (start < cs.length && cs[start].space) start++
90 if (start >= cs.length) break
91 const limit = lines.length === 0 ? width : restWidth
92 let used = 0
93 let lastBreak = -1 // the latest word start preceded by a space, past `start`
94 let k = start
95 let hitBreak = false
96 for (; k < cs.length; k++) {
97 const c = cs[k]
98 if (c.brk) {
99 hitBreak = true
100 break
101 }
102 if (!c.space && c.width > limit) return null
103 if (!c.space && k > start && cs[k - 1].space) lastBreak = k
104 // Spaces may hang past the edge; they are trimmed from the line's end.
105 if (!c.space && k > start && used + c.width > limit) break
106 used += c.width
107 }
108 if (hitBreak) {
109 lines.push([start, trimEnd(start, k)])
110 start = k + 1
111 forced = true
112 if (start === cs.length) lines.push([start, start]) // a break at the very end leaves an empty line
113 continue
114 }
115 if (k >= cs.length) {
116 lines.push([start, trimEnd(start, cs.length)])
117 break
118 }
119 const next = lastBreak > start ? lastBreak : k
120 lines.push([start, trimEnd(start, next)])
121 start = next
122 forced = false
123 }
124 if (lines.length === 0) lines.push([0, 0])
125 return lines
126}
127
128// Lays out one paragraph, or returns null when it cannot fit (a non-positive width, or a cluster
129// wider than a line): the caller then leaves the whole message to the engine.
130export function layoutParagraph(input: Run[], opts: LayoutOptions): Row[] | null {
131 if (!(opts.width >= 1) || !((opts.restWidth ?? opts.width) >= 1)) return null
132 const runs = input.map(r => ({ ...r, text: cleanText(r.text) }))
133 const text = runs.map(r => r.text).join('')
134 const styleAt = new Int32Array(text.length)
135 const isolated = new Uint8Array(text.length)
136 let at = 0
137 for (const r of runs) {
138 styleAt.fill(r.style, at, at + r.text.length)
139 if (r.isolate) isolated.fill(1, at, at + r.text.length)
140 at += r.text.length
141 }
142 if (opts.mode === 'visual' && opts.arabic === 'forms' && needsShaping(text)) return layoutShaped(text, styleAt, isolated, opts)
143 return place(text, styleAt, isolated, opts)
144}
145
146// Arabic-script letters take their contextual forms before anything is measured (visual mode
147// only: a terminal that reorders text itself also shapes it; see docs/design/arabic-persian.md).
148// The paragraph's direction is chosen on the logical text, since a lam-alef ligature counts one
149// letter where the source has two. The text is shaped once to find the line breaks (ligatures
150// change widths), then again with each row's start as a joining boundary, so no letter reaches
151// across a wrap; that second pass changes forms only, never lengths. Styles and isolate flags
152// follow each display code unit back to its source.
153function layoutShaped(text: string, styleAt: Int32Array, isolated: Uint8Array, opts: LayoutOptions): Row[] | null {
154 const level = resolveParagraph(text, isolated, opts.base, opts.share).level
155 const fixed: LayoutOptions = { ...opts, base: level & 1 ? 'rtl' : 'ltr' }
156 const first = shape(text, { isolated, styles: styleAt })
157 const cs = clustersOf(first.text)
158 const lines = breakLines(cs, opts.width, opts.restWidth ?? opts.width)
159 if (lines === null) return null
160 const cuts: number[] = []
161 for (const [a] of lines.slice(1)) if (a < cs.length) cuts.push(first.from[cs[a].start])
162 const second = cuts.length ? shape(text, { isolated, styles: styleAt, cuts }) : first
163 const shaped = second.text.length === first.text.length ? second : first
164 const style2 = new Int32Array(shaped.text.length)
165 const iso2 = new Uint8Array(shaped.text.length)
166 for (let i = 0; i < shaped.text.length; i++) {
167 style2[i] = styleAt[shaped.from[i]]
168 iso2[i] = isolated[shaped.from[i]]
169 }
170 return place(shaped.text, style2, iso2, fixed)
171}
172
173// Lays out text whose styles and isolate flags are given per code unit.
174function place(text: string, styleAt: Int32Array, isolated: Uint8Array, opts: LayoutOptions): Row[] | null {
175 const para = resolveParagraph(text, isolated, opts.base, opts.share)
176 const rtl = (para.level & 1) === 1
177 const cs = clustersOf(text)
178 const lines = breakLines(cs, opts.width, opts.restWidth ?? opts.width)
179 if (lines === null) return null
180 const rows: Row[] = []
181 for (const [first, last] of lines) {
182 const line = cs.slice(first, last)
183 let order = line.map((_, i) => i)
184 let final = line.map(c => para.levels[c.start])
185 if (opts.mode === 'visual') {
186 ;({ order, final } = reorderLine(final, line.map(c => l1Kind(c.text)), para.level))
187 }
188 const spans: Span[] = []
189 let width = 0
190 let lastDrawn = line.length - 1
191 while (lastDrawn >= 0 && !line[lastDrawn].drawn) lastDrawn--
192 for (const i of order) {
193 const c = line[i]
194 if (!c.drawn) continue
195 let glyph = c.text === '\t' ? ' '.repeat(c.width) : c.text
196 if (opts.mode === 'visual' && final[i] & 1) {
197 glyph = mirror(glyph) ?? glyph
198 if (opts.arabic !== 'forms') glyph = joinersFirst(glyph, i === lastDrawn || spans.length === 0)
199 }
200 const style = styleAt[c.start]
201 const prev = spans[spans.length - 1]
202 if (prev && prev.style === style) prev.text += glyph
203 else spans.push({ text: glyph, style })
204 width += c.width
205 }
206 rows.push({ spans, width, rtl })
207 }
208 return rows
209}
210
211// `letters` mode, right to left: a ZWNJ or ZWJ sits in the cluster of the Arabic-script letter
212// before it, after the letter and among its marks, yet it stands between that letter and the next
213// one. The next letter is drawn first, so the joiners go to the front of the cluster (the marks stay
214// on their letter): written to the terminal, they land between the two letters they separate, where
215// a terminal that shapes the row needs them (Persian `میخواهم`). When that next letter is not on
216// this row (a wrap there, or a reply still streaming) or nothing is drawn before the cluster, the
217// joiners are dropped: there is no neighbour to join or part from, and a leading zero-width
218// control would take a cell of its own in xterm.js and push the row's last letter onto the next one.
219// Other clusters (Hebrew, emoji sequences) and `forms` (shape.ts drops these joiners) keep their
220// bytes.
221const JOINERS = /[\u200c\u200d]/gu
222const ARABIC_LETTER = /^(?=\p{L})\p{Script=Arabic}/u
223function joinersFirst(glyph: string, drop: boolean): string {
224 if (!ARABIC_LETTER.test(glyph)) return glyph
225 const joiners = glyph.match(JOINERS)
226 if (!joiners) return glyph
227 const rest = glyph.replace(JOINERS, '')
228 return drop ? rest : joiners.join('') + rest
229}
230src/markdown.ts 511 lines1// A Markdown reader for assistant text blocks: a block pass (paragraphs, headings, lists, quotes,
2// fences, rules, tables) and an inline pass (CommonMark delimiter runs for emphasis, code spans,
3// links with balanced destinations). It covers what the mod draws itself and mirrors Claude
4// Code's own renderer (marked + its token printer, read from 2.1.288) where the output is
5// visible: `-` for every bullet, `1.`/`a.`/`i.` by depth, `▎` quotes in italics, links as
6// `text (url)` where the terminal has no hyperlinks, `---` for a rule.
7
8import type { Run } from './layout.ts'
9
10export const BOLD = 1
11export const ITALIC = 2
12export const CODE = 4
13export const LINK = 8
14export const STRIKE = 16
15export const UNDERLINE = 32
16export const DIM = 64
17
18export type Block =
19 | { kind: 'para'; text: string; heading: number; source: string }
20 | { kind: 'list'; ordered: boolean; start: number; items: Item[]; source: string }
21 | { kind: 'quote'; blocks: Block[]; source: string }
22 | { kind: 'code'; language: string; text: string; source: string }
23 | { kind: 'rule'; source: string }
24 | { kind: 'table'; source: string }
25 | { kind: 'mermaid'; source: string }
26 | { kind: 'space' }
27
28// `gap`: blank lines separated this item from the one before it (a loose list).
29export type Item = { marker: string; blocks: Block[]; gap: boolean }
30
31const FENCE = /^( {0,3})(`{3,}|~{3,})\s*(.*)$/
32const ATX = /^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$/
33const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/
34const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/
35const QUOTE = /^ {0,3}> ?(.*)$/
36const ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])([ \t]+|$)(.*)$/
37const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/
38const BLANK = /^[ \t]*$/
39
40const expandTabs = (line: string) => line.replace(/^[ \t]+/, ws => ws.replace(/\t/g, ' '))
41const indentOf = (line: string) => expandTabs(line).match(/^ */)![0].length
42
43// Lines that end a paragraph (CommonMark "can interrupt a paragraph"); an ordered item only when
44// it starts at 1, a bullet only when it has content.
45function interrupts(line: string): boolean {
46 if (FENCE.test(line) || ATX.test(line) || RULE.test(line) || QUOTE.test(line)) return true
47 const m = line.match(ITEM)
48 if (!m || m[4].trim() === '') return false
49 return !/\d/.test(m[2]) || /^1[.)]$/.test(m[2])
50}
51
52export function parseBlocks(markdown: string): Block[] {
53 return blocksOf(markdown.replace(/\r\n?/g, '\n').split('\n'))
54}
55
56function blocksOf(lines: string[]): Block[] {
57 const blocks: Block[] = []
58 let i = 0
59 const pushSpace = () => {
60 if (blocks.length && blocks[blocks.length - 1].kind !== 'space') blocks.push({ kind: 'space' })
61 }
62 while (i < lines.length) {
63 const line = lines[i]
64 if (BLANK.test(line)) {
65 pushSpace()
66 i++
67 continue
68 }
69 const fence = line.match(FENCE)
70 if (fence && !(fence[2][0] === '`' && fence[3].includes('`'))) {
71 const indent = fence[1].length
72 const mark = fence[2]
73 const body: string[] = []
74 let j = i + 1
75 for (; j < lines.length; j++) {
76 const close = lines[j].match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)
77 if (close && close[1][0] === mark[0] && close[1].length >= mark.length) break
78 body.push(lines[j].replace(new RegExp(`^ {0,${indent}}`), ''))
79 }
80 const language = (fence[3].trim().split(/\s+/)[0] ?? '').toLowerCase()
81 const source = lines.slice(i, Math.min(j + 1, lines.length)).join('\n')
82 blocks.push(language === 'mermaid' ? { kind: 'mermaid', source } : { kind: 'code', language, text: body.join('\n'), source })
83 i = j + 1
84 continue
85 }
86 if (indentOf(line) >= 4) {
87 const body: string[] = []
88 let j = i
89 for (; j < lines.length && (indentOf(lines[j]) >= 4 || BLANK.test(lines[j])); j++) body.push(expandTabs(lines[j]).slice(4))
90 while (body.length && BLANK.test(body[body.length - 1])) {
91 body.pop()
92 j--
93 }
94 blocks.push({ kind: 'code', language: '', text: body.join('\n'), source: lines.slice(i, j).join('\n') })
95 i = j
96 continue
97 }
98 const atx = line.match(ATX)
99 if (atx) {
100 blocks.push({ kind: 'para', text: atx[2].replace(/[ \t]+#+[ \t]*$|^#+[ \t]*$/, '').trim(), heading: atx[1].length, source: line })
101 i++
102 continue
103 }
104 if (RULE.test(line)) {
105 blocks.push({ kind: 'rule', source: line })
106 i++
107 continue
108 }
109 if (QUOTE.test(line)) {
110 const inner: string[] = []
111 let j = i
112 for (; j < lines.length; j++) {
113 const q = lines[j].match(QUOTE)
114 if (q) inner.push(q[1])
115 else if (!BLANK.test(lines[j]) && !interrupts(lines[j]) && inner.length && !BLANK.test(inner[inner.length - 1])) inner.push(lines[j]) // lazy continuation
116 else break
117 }
118 blocks.push({ kind: 'quote', blocks: blocksOf(inner), source: lines.slice(i, j).join('\n') })
119 i = j
120 continue
121 }
122 const item = line.match(ITEM)
123 if (item) {
124 const ordered = /\d/.test(item[2])
125 const start = ordered ? parseInt(item[2], 10) : 1
126 const items: Item[] = []
127 let j = i
128 let gap = false
129 while (j < lines.length) {
130 const m = lines[j].match(ITEM)
131 if (!m || /\d/.test(m[2]) !== ordered || (!ordered && m[2] !== item[2]) || (ordered && m[2].slice(-1) !== item[2].slice(-1))) break
132 const pad = m[3].replace(/\t/g, ' ').length
133 const contentIndent = m[1].length + m[2].length + (m[4] === '' ? 1 : pad > 4 ? 1 : pad)
134 const body = [m[4] === '' ? '' : (pad > 4 ? ' '.repeat(pad - 1) : '') + m[4]]
135 let k = j + 1
136 for (; k < lines.length; k++) {
137 const l = lines[k]
138 if (BLANK.test(l)) {
139 // a blank line stays inside the item only when indented content follows it
140 let n = k
141 while (n < lines.length && BLANK.test(lines[n])) n++
142 if (n < lines.length && indentOf(lines[n]) >= contentIndent) {
143 for (; k < n; k++) body.push('')
144 k--
145 continue
146 }
147 break
148 }
149 if (indentOf(l) >= contentIndent) body.push(expandTabs(l).slice(contentIndent))
150 else if (!interrupts(l) && !ITEM.test(l) && !BLANK.test(body[body.length - 1])) body.push(l.trimStart()) // lazy continuation
151 else break
152 }
153 items.push({ marker: m[2], blocks: blocksOf(body), gap })
154 gap = false
155 j = k
156 // a blank line between items of the same list
157 let n = j
158 while (n < lines.length && BLANK.test(lines[n])) n++
159 const next = n < lines.length ? lines[n].match(ITEM) : null
160 if (n > j && next && /\d/.test(next[2]) === ordered && (ordered ? next[2].slice(-1) === item[2].slice(-1) : next[2] === item[2])) {
161 j = n
162 gap = true
163 }
164 }
165 blocks.push({ kind: 'list', ordered, start, items, source: lines.slice(i, j).join('\n') })
166 i = j
167 continue
168 }
169 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-')) {
170 let j = i + 2
171 for (; j < lines.length && lines[j].includes('|') && !BLANK.test(lines[j]); j++);
172 blocks.push({ kind: 'table', source: lines.slice(i, j).join('\n') })
173 i = j
174 continue
175 }
176 // A paragraph: its lines until a blank line or a block that can interrupt it. A setext
177 // underline turns it into a heading.
178 const para: string[] = [line]
179 let j = i + 1
180 let heading = 0
181 for (; j < lines.length; j++) {
182 const l = lines[j]
183 const setext = l.match(SETEXT)
184 if (setext) {
185 heading = setext[1][0] === '=' ? 1 : 2
186 j++
187 break
188 }
189 if (BLANK.test(l) || interrupts(l)) break
190 if (l.includes('|') && j + 1 < lines.length && TABLE_SEP.test(lines[j + 1]) && lines[j + 1].includes('-')) break
191 para.push(l)
192 }
193 blocks.push({ kind: 'para', text: joinLines(para), heading, source: lines.slice(i, j).join('\n') })
194 i = j
195 }
196 while (blocks.length && blocks[blocks.length - 1].kind === 'space') blocks.pop()
197 while (blocks.length && blocks[0].kind === 'space') blocks.shift()
198 return blocks
199}
200
201// Paragraph lines: leading indentation dropped, hard-break markers (two trailing spaces, a
202// trailing backslash) removed. Claude Code draws soft and hard breaks alike, as line breaks.
203function joinLines(lines: string[]): string {
204 return lines
205 .map((l, i) => {
206 let t = l.replace(/^[ \t]+/, '')
207 if (i < lines.length - 1 && /(?<!\\)\\$/.test(t)) t = t.slice(0, -1)
208 return t.replace(/[ \t]+$/, '')
209 })
210 .join('\n')
211}
212
213// ---------------------------------------------------------------------------------------------
214// Inline: CommonMark's delimiter-run algorithm for `*`, `_` (and GFM `~`), on a node list built
215// around atomic code spans, links and escapes. Unclosed markers stay literal (a reply that is
216// still streaming shows them as typed, as Claude Code itself does).
217
218type Node = { text: string; style: number; isolate?: boolean }
219type Delim = { node: number; ch: string; count: number; orig: number; canOpen: boolean; canClose: boolean; active: boolean }
220
221const PUNCT = /[\p{P}\p{S}]/u
222const SPACE_CH = /\s/u
223const ESCAPABLE = /[!-/:-@[-`{-~]/
224
225export function parseInline(s: string, base = 0): Run[] {
226 const nodes: Node[] = []
227 const delims: Delim[] = []
228 let buf = ''
229 const flush = () => {
230 if (buf) nodes.push({ text: buf, style: base })
231 buf = ''
232 }
233 let i = 0
234 while (i < s.length) {
235 const c = s[i]
236 if (c === '\\' && i + 1 < s.length && ESCAPABLE.test(s[i + 1])) {
237 buf += s[i + 1]
238 i += 2
239 continue
240 }
241 if (c === '`') {
242 let n = 0
243 while (s[i + n] === '`') n++
244 const close = findBackticks(s, n, i + n)
245 if (close >= 0) {
246 flush()
247 let code = s.slice(i + n, close).replace(/\n/g, ' ')
248 if (code.length > 2 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim() !== '') code = code.slice(1, -1)
249 nodes.push({ text: code, style: base | CODE, isolate: true })
250 i = close + n
251 continue
252 }
253 buf += s.slice(i, i + n)
254 i += n
255 continue
256 }
257 if (c === '<') {
258 const m = s.slice(i).match(/^<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+)>/)
259 if (m) {
260 flush()
261 nodes.push({ text: m[1].replace(/^mailto:/i, ''), style: base | LINK, isolate: true })
262 i += m[0].length
263 continue
264 }
265 }
266 if (c === '[' || (c === '!' && s[i + 1] === '[')) {
267 const link = parseLink(s, c === '!' ? i + 1 : i)
268 if (link) {
269 flush()
270 const image = c === '!'
271 const label = parseInline(link.text, base | LINK)
272 const shown = label.map(r => r.text).join('')
273 if (image) {
274 if (!shown && !link.title) nodes.push({ text: link.href, style: base, isolate: true })
275 else {
276 for (const r of label) nodes.push({ ...r, style: r.style & ~LINK })
277 nodes.push({ text: (shown ? ' ' : '') + '(', style: base })
278 nodes.push({ text: link.href + (link.title ? ` "${link.title}"` : ''), style: base, isolate: true })
279 nodes.push({ text: ')', style: base })
280 }
281 } else {
282 for (const r of label) nodes.push(r)
283 if (shown !== link.href) {
284 nodes.push({ text: ' (', style: base })
285 nodes.push({ text: link.href.replace(/^mailto:/i, ''), style: base, isolate: true })
286 nodes.push({ text: ')', style: base })
287 }
288 if (link.title) nodes.push({ text: ` ("${link.title}")`, style: base })
289 }
290 i = link.end
291 continue
292 }
293 }
294 if (c === '*' || c === '_' || c === '~') {
295 let n = 0
296 while (s[i + n] === c) n++
297 if (c === '~' && n > 2) {
298 buf += s.slice(i, i + n)
299 i += n
300 continue
301 }
302 const before = i > 0 ? s[i - 1] : ' '
303 const after = i + n < s.length ? s[i + n] : ' '
304 const left = !SPACE_CH.test(after) && (!PUNCT.test(after) || SPACE_CH.test(before) || PUNCT.test(before))
305 const right = !SPACE_CH.test(before) && (!PUNCT.test(before) || SPACE_CH.test(after) || PUNCT.test(after))
306 let canOpen = left
307 let canClose = right
308 if (c === '_') {
309 canOpen = left && (!right || PUNCT.test(before))
310 canClose = right && (!left || PUNCT.test(after))
311 }
312 flush()
313 nodes.push({ text: c.repeat(n), style: base })
314 delims.push({ node: nodes.length - 1, ch: c, count: n, orig: n, canOpen, canClose, active: true })
315 i += n
316 continue
317 }
318 buf += c
319 i++
320 }
321 flush()
322 processEmphasis(nodes, delims)
323 const out: Run[] = []
324 for (const n of nodes) {
325 if (!n.text) continue
326 const prev = out[out.length - 1]
327 if (prev && prev.style === n.style && !prev.isolate && !n.isolate) prev.text += n.text
328 else out.push(n.isolate ? { text: n.text, style: n.style, isolate: true } : { text: n.text, style: n.style })
329 }
330 return out
331}
332
333function processEmphasis(nodes: Node[], delims: Delim[]) {
334 const bottom = new Map<string, number>()
335 for (let c = 0; c < delims.length; c++) {
336 const closer = delims[c]
337 if (!closer.active || !closer.canClose) continue
338 const key = `${closer.ch}${closer.canOpen ? 1 : 0}${closer.orig % 3}`
339 let matched = false
340 for (let o = c - 1; o > (bottom.get(key) ?? -1); o--) {
341 const opener = delims[o]
342 if (!opener.active || opener.ch !== closer.ch || !opener.canOpen || opener.count === 0) continue
343 if (closer.ch === '~') {
344 if (opener.count !== closer.count) continue
345 } else if ((opener.canClose || closer.canOpen) && (opener.orig + closer.orig) % 3 === 0 && !(opener.orig % 3 === 0 && closer.orig % 3 === 0)) {
346 continue
347 }
348 const use = closer.ch === '~' ? closer.count : opener.count >= 2 && closer.count >= 2 ? 2 : 1
349 const style = closer.ch === '~' ? STRIKE : use === 2 ? BOLD : ITALIC
350 for (let k = opener.node + 1; k < closer.node; k++) nodes[k].style |= style
351 for (let d = o + 1; d < c; d++) delims[d].active = false
352 opener.count -= use
353 closer.count -= use
354 nodes[opener.node].text = opener.ch.repeat(opener.count)
355 nodes[closer.node].text = closer.ch.repeat(closer.count)
356 if (opener.count === 0) opener.active = false
357 matched = true
358 break
359 }
360 if (matched) {
361 if (closer.count > 0) c-- // the same closer may close another opener
362 else closer.active = false
363 continue
364 }
365 bottom.set(key, c - 1)
366 if (!closer.canOpen) closer.active = false
367 }
368}
369
370function findBackticks(s: string, n: number, from: number): number {
371 for (let j = from; j < s.length; j++) {
372 if (s[j] !== '`') continue
373 let k = 0
374 while (s[j + k] === '`') k++
375 if (k === n) return j
376 j += k - 1
377 }
378 return -1
379}
380
381// `[text](destination "title")` starting at `[`: the text may nest brackets and hold code
382// spans; the destination is `<...>` or a run without spaces whose parentheses balance.
383function parseLink(s: string, at: number): { text: string; href: string; title: string; end: number } | null {
384 let depth = 0
385 let j = at
386 for (; j < s.length; j++) {
387 const ch = s[j]
388 if (ch === '\\') {
389 j++
390 continue
391 }
392 if (ch === '`') {
393 let n = 0
394 while (s[j + n] === '`') n++
395 const close = findBackticks(s, n, j + n)
396 j = close >= 0 ? close + n - 1 : j + n - 1
397 continue
398 }
399 if (ch === '[') depth++
400 else if (ch === ']' && --depth === 0) break
401 }
402 if (j >= s.length || s[j + 1] !== '(') return null
403 const text = s.slice(at + 1, j)
404 let k = j + 2
405 while (s[k] === ' ' || s[k] === '\t' || s[k] === '\n') k++
406 let href = ''
407 if (s[k] === '<') {
408 const close = s.indexOf('>', k)
409 if (close < 0 || s.slice(k, close).includes('\n')) return null
410 href = s.slice(k + 1, close)
411 k = close + 1
412 } else {
413 let parens = 0
414 const startHref = k
415 for (; k < s.length; k++) {
416 const ch = s[k]
417 if (ch === '\\' && k + 1 < s.length) {
418 k++
419 continue
420 }
421 if (/\s/.test(ch)) break
422 if (ch === '(') parens++
423 else if (ch === ')') {
424 if (parens === 0) break
425 parens--
426 }
427 }
428 if (parens !== 0) return null
429 href = s.slice(startHref, k).replace(/\\([!-/:-@[-`{-~])/g, '$1')
430 }
431 let title = ''
432 const ws = k
433 while (s[k] === ' ' || s[k] === '\t' || s[k] === '\n') k++
434 if (k > ws && (s[k] === '"' || s[k] === "'" || s[k] === '(')) {
435 const closeCh = s[k] === '(' ? ')' : s[k]
436 const close = s.indexOf(closeCh, k + 1)
437 if (close < 0) return null
438 title = s.slice(k + 1, close)
439 k = close + 1
440 while (s[k] === ' ' || s[k] === '\t' || s[k] === '\n') k++
441 }
442 if (s[k] !== ')') return null
443 return { text, href, title, end: k + 1 }
444}
445
446// List markers as Claude Code prints them: `-` for every bullet; ordered numbers by depth
447// (1. at the top, a. one level down, i. below that).
448// A GFM table's cells: the header, each column's alignment (from the delimiter row) and the body
449// rows, padded or cut to the delimiter row's column count. `\\|` is a literal pipe. Null when the
450// header's count differs from the delimiter row's (then it is no table, as for marked).
451export type Align = 'left' | 'center' | 'right' | null
452export type Table = { header: string[]; align: Align[]; rows: string[][] }
453
454export function parseTable(source: string): Table | null {
455 const lines = source.split('\n')
456 if (lines.length < 2) return null
457 const align = splitRow(lines[1]).map((c): Align => {
458 const l = c.startsWith(':')
459 const r = c.endsWith(':')
460 return l && r ? 'center' : r ? 'right' : l ? 'left' : null
461 })
462 const header = splitRow(lines[0])
463 if (header.length !== align.length) return null
464 const fit = (cells: string[]) => align.map((_, i) => cells[i] ?? '')
465 return { header, align, rows: lines.slice(2).map(l => fit(splitRow(l))) }
466}
467
468function splitRow(line: string): string[] {
469 let s = line.trim()
470 if (s.startsWith('|')) s = s.slice(1)
471 if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1)
472 const cells: string[] = []
473 let cur = ''
474 for (let i = 0; i < s.length; i++) {
475 if (s[i] === '\\' && s[i + 1] === '|') {
476 cur += '|'
477 i++
478 } else if (s[i] === '|') {
479 cells.push(cur.trim())
480 cur = ''
481 } else cur += s[i]
482 }
483 cells.push(cur.trim())
484 return cells
485}
486
487export function markerText(ordered: boolean, number: number, depth: number): string {
488 if (!ordered) return '-'
489 if (depth === 1) return `${alpha(number)}.`
490 if (depth === 2 && number <= 3999) return `${roman(number)}.`
491 return `${number}.`
492}
493
494function alpha(n: number): string {
495 let t = ''
496 while (n > 0) {
497 n--
498 t = String.fromCharCode(97 + (n % 26)) + t
499 n = Math.floor(n / 26)
500 }
501 return t
502}
503
504const ROMAN: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']]
505
506function roman(n: number): string {
507 let t = ''
508 for (const [v, r] of ROMAN) while (n >= v) (t += r), (n -= v)
509 return t
510}
511src/render.ts 514 lines1// From an assistant text block to a row model the hooks module turns into elements. Pure: no
2// engine calls, so it is unit-tested in Node and memoized per (text, width, settings).
3//
4// Blocks that hold RTL text are laid out here; blocks that hold none go to the engine's own
5// `Markdown` (consecutive ones merged into one leaf), fences to its `Code` (their lines with RTL
6// text are drawn here, code.ts). A reply the model
7// cannot represent (mermaid, a table with RTL text, a width too small for its content, a leaf
8// over the engine's limits) returns null, and the hook leaves the whole message to the engine.
9
10import { hasRtl, resolveParagraph, type Base } from './bidi.ts'
11import { codeParts } from './code.ts'
12import { cleanText, layoutParagraph, type Arabic, type Mode, type Row, type Run, type Span } from './layout.ts'
13import { BOLD, DIM, ITALIC, UNDERLINE, markerText, parseBlocks, parseInline, parseTable, type Align, type Block, type Table } from './markdown.ts'
14import { textWidth } from './width.ts'
15
16// The reply bullet, drawn in the theme's text color.
17export const BULLET = 128
18
19export type Settings = { mode: Mode; base: Base; share: number; arabic?: Arabic }
20
21// `lead`: the start-side prefix of a text row (gutter, list markers, quote bars), already in the
22// visual order of the row's side: drawn after the spans on an RTL row, before them on an LTR one.
23// `rtl` is the row's side (its alignment and where its lead goes), not its text's direction: in a
24// list it is the list's side, so an English item of a Hebrew list sits under its siblings, its own
25// text in LTR order. A code row's `inset` keeps that many cells free on the right.
26// `paint`: drawn in explicit colors even without RTL text (a table's rows, so its borders keep one
27// color; the code rows drawn here, so their comments and strings keep theirs).
28export type RowModel =
29 | { kind: 'text'; spans: Span[]; rtl: boolean; lead: Span[]; paint?: boolean }
30 | { kind: 'code'; language: string; source: string; indent: number; bullet: boolean; inset?: number }
31 | { kind: 'markdown'; text: string; bullet: boolean }
32 | { kind: 'blank' }
33
34export type Model = { width: number; rows: RowModel[] }
35
36// The start side's gutter holds the reply bullet; the far side keeps the same margin.
37export const GUTTER = 2
38export const MARGIN = 2
39// The engine refuses a Code or Markdown leaf longer than this.
40export const LEAF_MAX = 10000
41
42export function shouldDraw(text: string): boolean {
43 return hasRtl(text)
44}
45
46// One container level's prefix: its first row's text, every later row's text, and their style.
47type Seg = { first: string; rest: string; style: number; used: boolean }
48
49const seg = (first: string, rest: string, style = 0): Seg => ({ first, rest, style, used: false })
50
51// A prefix as spans for one row. On an RTL row the segments come in reverse and each is mirrored:
52// `- ` becomes ` -`, `10. ` becomes ` .10` (the number keeps its digits' order, as UBA lays out a
53// marker inside RTL text), the quote bar moves to the right edge of its cell.
54function leadOf(segs: Seg[], rtl: boolean): Span[] {
55 const out: Span[] = []
56 const parts = segs.map(s => ({ text: s.used ? s.rest : s.first, style: s.style }))
57 for (const s of segs) s.used = true
58 for (const p of rtl ? parts.reverse() : parts) {
59 for (const span of rtl ? mirrorPrefix(p.text, p.style) : splitBullet(p.text, p.style)) {
60 const prev = out[out.length - 1]
61 if (prev && prev.style === span.style) prev.text += span.text
62 else out.push(span)
63 }
64 }
65 return out
66}
67
68// The bullet and the quote bar carry their own style; the spaces around them stay plain.
69function splitBullet(text: string, style: number): Span[] {
70 const m = text.match(/^(\S*)(\s*)$/)
71 if (!m || !style) return [{ text, style: 0 }]
72 return [{ text: m[1], style }, { text: m[2], style: 0 }].filter(s => s.text)
73}
74
75function mirrorPrefix(text: string, style: number): Span[] {
76 const m = text.match(/^(\S*)(\s*)$/)
77 if (!m || !m[1]) return [{ text, style: 0 }]
78 const mark = m[1] === '▎' ? '▕' : /\d/.test(m[1]) ? m[1].replace(/^(\d+)(\.)$/, '$2$1') : m[1].replace(/^([a-z]+)(\.)$/, '$2$1')
79 return [{ text: m[2], style: 0 }, { text: mark, style }].filter(s => s.text)
80}
81
82class NoFit extends Error {}
83
84export function modelAssistant(text: string, columns: number, isFirstOfReply: boolean, s: Settings): Model | null {
85 const width = columns - 1
86 if (!(width > GUTTER + MARGIN)) return null
87 const blocks = parseBlocks(text)
88 if (blocks.some(b => b.kind === 'mermaid')) return null
89 const flow = replyFlow(blocks, s)
90 const rows: RowModel[] = []
91 let bulletPending = isFirstOfReply
92 const gutter = () => {
93 const g = seg(bulletPending ? '● ' : ' ', ' ', BULLET)
94 bulletPending = false
95 return g
96 }
97
98 // Lays out blocks inside containers (`segs`, outermost first, the gutter included). `side`: the
99 // enclosing list's side (true: RTL), which places every row inside it; null outside lists.
100 const walk = (list: Block[], segs: Seg[], depth: number, quoteStyle: number, side: boolean | null): void => {
101 const prefixCells = segs.reduce((a, g) => a + textWidth(g.first), 0)
102 const avail = width - prefixCells - MARGIN
103 if (avail < 1) throw new NoFit()
104 for (let i = 0; i < list.length; i++) {
105 const b = list[i]
106 switch (b.kind) {
107 case 'space':
108 rows.push({ kind: 'blank' })
109 break
110 case 'rule': {
111 const rtl = side ?? false
112 rows.push({ kind: 'text', spans: [{ text: '---', style: 0 }], rtl, lead: leadOf(segs, rtl) })
113 break
114 }
115 case 'code':
116 // in an RTL list the prefix is on the right: the code keeps its left origin at the margin
117 rows.push(...(side ? codeRows(b, MARGIN, false, width, s, prefixCells) : codeRows(b, prefixCells, false, width, s)))
118 for (const g of segs) g.used = true
119 break
120 case 'table': {
121 const t = parseTable(b.source)
122 if (t === null) {
123 walk([{ kind: 'para', text: b.source, heading: 0, source: b.source }], segs, depth, quoteStyle, side)
124 break
125 }
126 for (const line of tableLines(t, avail + 1, quoteStyle, s, flow)) {
127 const rtl = side ?? line.rtl
128 rows.push({ kind: 'text', spans: line.spans, rtl, lead: leadOf(segs, rtl), paint: true })
129 }
130 break
131 }
132 case 'mermaid':
133 throw new NoFit()
134 case 'para': {
135 const style = b.heading === 1 ? BOLD | ITALIC | UNDERLINE : b.heading ? BOLD : quoteStyle
136 const runs: Run[] = parseInline(b.text, style)
137 const laid = layoutParagraph(runs.length ? runs : [{ text: '', style }], { width: avail, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
138 if (laid === null) throw new NoFit()
139 for (const r of laid) rows.push({ kind: 'text', spans: r.spans, rtl: side ?? r.rtl, lead: leadOf(segs, side ?? r.rtl) })
140 // Claude Code ends a heading with an empty line of its own
141 if (b.heading && i + 1 < list.length && list[i + 1].kind !== 'space') rows.push({ kind: 'blank' })
142 break
143 }
144 case 'quote':
145 walk(b.blocks, [...segs, seg('▎ ', '▎ ', DIM)], depth, quoteStyle | ITALIC, side)
146 break
147 case 'list': {
148 // the outermost list decides for all of it: its own prose, else the reply's, else LTR
149 const listSide = side ?? replyFlow([b], s) ?? flow ?? false
150 b.items.forEach((item, n) => {
151 if (item.gap) rows.push({ kind: 'blank' })
152 const marker = markerText(b.ordered, b.start + n, depth)
153 const itemSeg = seg(marker + ' ', ' '.repeat(textWidth(marker) + 1))
154 const inner = [...segs, itemSeg]
155 if (item.blocks.length === 0 || item.blocks[0].kind !== 'para') {
156 rows.push({ kind: 'text', spans: [], rtl: listSide, lead: leadOf(inner, listSide) })
157 }
158 walk(item.blocks, inner, depth + 1, quoteStyle, listSide)
159 })
160 break
161 }
162 }
163 }
164 }
165
166 try {
167 let pendingLeaf: string[] = []
168 let leafEndsInHeading = false
169 const flushLeaf = (beforeBlock: boolean) => {
170 if (!pendingLeaf.length) return
171 const leaf = cleanText(pendingLeaf.join('\n\n'))
172 if (leaf.length > LEAF_MAX) throw new NoFit()
173 rows.push({ kind: 'markdown', text: leaf, bullet: bulletPending })
174 if (beforeBlock && leafEndsInHeading) rows.push({ kind: 'blank' })
175 bulletPending = false
176 pendingLeaf = []
177 }
178 for (let i = 0; i < blocks.length; i++) {
179 const b = blocks[i]
180 if (b.kind === 'space') {
181 // a blank between two native blocks belongs to their merged leaf
182 const prevLeaf = pendingLeaf.length > 0
183 const next = blocks[i + 1]
184 if (prevLeaf && next && isNative(next)) continue
185 flushLeaf(false)
186 rows.push({ kind: 'blank' })
187 continue
188 }
189 if (isNative(b)) {
190 pendingLeaf.push(b.source)
191 leafEndsInHeading = b.kind === 'para' && b.heading > 0
192 continue
193 }
194 flushLeaf(true)
195 // A top-level block's rows depend only on its source, the width, the settings and whether it
196 // carries the reply bullet: kept, so a streaming reply lays out only its last block again.
197 const key = `${width}|${s.mode}|${s.base}|${s.share}|${s.arabic ?? 'letters'}|${bulletPending}|${flow}|${b.source}`
198 const kept = blockMemo.get(key, () => {
199 const from = rows.length
200 try {
201 if (b.kind === 'code') rows.push(...codeRows(b, GUTTER, bulletPending, width, s))
202 else walk([b], [gutter()], 0, 0, null)
203 } catch (err) {
204 if (err instanceof NoFit) return null
205 throw err
206 }
207 return rows.splice(from)
208 })
209 if (kept === null) throw new NoFit()
210 bulletPending = false
211 rows.push(...kept)
212 if (b.kind === 'para' && b.heading && i + 1 < blocks.length && blocks[i + 1].kind !== 'space') rows.push({ kind: 'blank' })
213 }
214 flushLeaf(false)
215 } catch (err) {
216 if (err instanceof NoFit) return null
217 throw err
218 }
219 return { width, rows }
220}
221
222// A table as Claude Code 2.1.288 draws one (its markdown table printer): column widths from each
223// column's longest word and full text, shared out over the room; box-drawing borders; a header
224// row centered; cells wrapped and centered vertically; past 4 lines in a cell, or wider than the
225// room, the vertical form (`header: value` per cell, rows apart by a rule); past 200 rows a note.
226// An RTL table (its header row resolved as a paragraph) runs its columns from the right and aligns
227// a cell to its start: no alignment and `:--` mean the right edge there, `--:` the left.
228const TABLE_MIN = 3
229const TABLE_MAX_LINES = 4
230const TABLE_MAX_ROWS = 200
231
232type Line = { spans: Span[]; rtl: boolean }
233
234function tableLines(t: Table, room: number, style: number, s: Settings, flow: boolean | null): Line[] {
235 const truncated = Math.max(0, t.rows.length - TABLE_MAX_ROWS)
236 const body = t.rows.slice(0, TABLE_MAX_ROWS)
237 const runsOf = (text: string): Run[] => {
238 const runs = parseInline(text, style)
239 return runs.length ? runs : [{ text: '', style }]
240 }
241 const head = t.header.map(runsOf)
242 const cells = body.map(r => r.map(runsOf))
243 const plain = (runs: Run[]) => runs.map(r => r.text).join('')
244 const all = [head, ...cells]
245 const n = t.align.length
246 // The direction: right-to-left when the header row (the table's own words) reads so; otherwise
247 // the reply's (`flow`), so that in an Arabic reply a table comparing `git merge` with
248 // `git rebase` runs from the right; in a reply that is only tables, the whole table's. Code spans
249 // and link destinations do not count.
250 const prose = (runs: Run[]) => runs.filter(r => !r.isolate).map(r => r.text).join('')
251 const rtlText = (text: string) => (resolveParagraph(text, null, s.base, s.share).level & 1) === 1
252 const rtl = rtlText(head.map(prose).join(' ')) || (flow ?? rtlText(all.flat().map(prose).join(' ')))
253
254 const words = (runs: Run[]) => Math.max(TABLE_MIN, ...plain(runs).split(/\s+/).filter(Boolean).map(textWidth))
255 const full = (runs: Run[]) => Math.max(textWidth(plain(runs)), TABLE_MIN)
256 const minW = t.align.map((_, c) => Math.max(...all.map(r => words(r[c]))))
257 const ideal = t.align.map((_, c) => Math.max(...all.map(r => full(r[c]))))
258 const room0 = Math.max(room - (1 + n * 3), n * TABLE_MIN)
259 const sumMin = minW.reduce((a, b) => a + b, 0)
260 const sumIdeal = ideal.reduce((a, b) => a + b, 0)
261 let widths: number[]
262 if (sumIdeal <= room0) widths = ideal
263 else if (sumMin <= room0) {
264 const extra = room0 - sumMin
265 const slack = ideal.map((v, c) => v - minW[c])
266 const total = slack.reduce((a, b) => a + b, 0)
267 widths = minW.map((v, c) => (total === 0 ? v : v + Math.floor((slack[c] / total) * extra)))
268 } else widths = minW.map(v => Math.max(Math.floor((v * room0) / sumMin), TABLE_MIN))
269
270 const lay = (runs: Run[], width: number) => {
271 const laid = layoutParagraph(runs, { width, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
272 if (laid === null) throw new NoFit()
273 return laid
274 }
275 const laidHead = head.map((r, c) => lay(r, widths[c]))
276 const laidBody = cells.map(row => row.map((r, c) => lay(r, widths[c])))
277 const tallest = Math.max(...[laidHead, ...laidBody].flat().map(l => l.length))
278 const lineWidth = 1 + widths.reduce((a, w) => a + w + 3, 0)
279 if (tallest > TABLE_MAX_LINES || lineWidth > room) return verticalTable(t, head, cells, room, rtl, truncated, s)
280
281 const cols = rtl ? [...widths.keys()].reverse() : [...widths.keys()]
282 const alignOf = (c: number, header: boolean): Align => {
283 if (header) return 'center'
284 const a = t.align[c]
285 if (!rtl) return a ?? 'left'
286 return a === 'center' ? 'center' : a === 'right' ? 'left' : 'right'
287 }
288 const border = (l: string, m: string, r: string): Line => ({
289 spans: [{ text: l + cols.map(c => '\u2500'.repeat(widths[c] + 2)).join(m) + r, style: 0 }],
290 rtl,
291 })
292 const rowLines = (laid: Row[][], header: boolean): Line[] => {
293 const height = Math.max(1, ...laid.map(l => l.length))
294 const out: Line[] = []
295 for (let g = 0; g < height; g++) {
296 const spans: Span[] = [{ text: '\u2502', style: 0 }]
297 for (const c of cols) {
298 const top = Math.floor((height - laid[c].length) / 2)
299 const line = g - top >= 0 && g - top < laid[c].length ? laid[c][g - top] : null
300 const free = Math.max(0, widths[c] - (line ? line.width : 0))
301 const a = alignOf(c, header)
302 const before = a === 'center' ? Math.floor(free / 2) : a === 'right' ? free : 0
303 spans.push({ text: ' ' + ' '.repeat(before), style: 0 })
304 if (line) spans.push(...line.spans)
305 spans.push({ text: ' '.repeat(free - before) + ' \u2502', style: 0 })
306 }
307 out.push({ spans: merge(spans), rtl })
308 }
309 return out
310 }
311 const lines: Line[] = [border('\u250C', '\u252C', '\u2510'), ...rowLines(laidHead, true), border('\u251C', '\u253C', '\u2524')]
312 laidBody.forEach((laid, i) => {
313 lines.push(...rowLines(laid, false))
314 if (i < laidBody.length - 1) lines.push(border('\u251C', '\u253C', '\u2524'))
315 })
316 lines.push(border('\u2514', '\u2534', '\u2518'))
317 if (truncated > 0) lines.push(...moreRows(truncated, room, s))
318 return lines
319}
320
321// The vertical form: per row, each cell as `header: value` (the header bold), rows apart by a rule.
322function verticalTable(t: Table, head: Run[][], cells: Run[][][], room: number, rtl: boolean, truncated: number, s: Settings): Line[] {
323 const rule: Line = { spans: [{ text: '\u2500'.repeat(Math.min(room, 40)), style: 0 }], rtl }
324 const out: Line[] = []
325 // A table with no body rows still shows its header, one cell per line.
326 if (cells.length === 0) {
327 for (const runs of head) {
328 const name = runs.map(r => ({ ...r, style: r.style | BOLD, text: r.text.replace(/\s+/g, ' ') }))
329 if (!name.some(r => r.text.trim())) continue
330 const laid = layoutParagraph(name, { width: room, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
331 if (laid === null) throw new NoFit()
332 for (const r of laid) out.push({ spans: r.spans, rtl: r.rtl })
333 }
334 }
335 for (const row of cells) {
336 const lines: Line[] = []
337 row.forEach((runs, c) => {
338 const name = head[c].map(r => r.text).join('').trim()
339 const value = runs.map(r => ({ ...r, text: r.text.replace(/\s+/g, ' ') }))
340 if (!name && !value.some(r => r.text.trim())) return
341 const para: Run[] = name ? [{ text: name, style: BOLD }, { text: ': ', style: 0 }, ...value] : value
342 const laid = layoutParagraph(para, { width: room, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
343 if (laid === null) throw new NoFit()
344 for (const r of laid) lines.push({ spans: r.spans, rtl: r.rtl })
345 })
346 if (!lines.length) continue
347 if (out.length) out.push(rule)
348 out.push(...lines)
349 }
350 if (truncated > 0) {
351 if (out.length) out.push(rule)
352 out.push(...moreRows(truncated, room, s))
353 }
354 return out
355}
356
357// The note past the row cap, laid out to the room like any paragraph.
358function moreRows(n: number, room: number, s: Settings): Line[] {
359 const text = `\u2026 ${n.toLocaleString('en-US')} more ${n === 1 ? 'row' : 'rows'} not shown`
360 const laid = layoutParagraph([{ text, style: 0 }], { width: room, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
361 if (laid === null) throw new NoFit()
362 return laid.map(r => ({ spans: r.spans, rtl: r.rtl }))
363}
364
365// The direction of a reply's prose (paragraphs, also inside lists and quotes; not tables, fences,
366// code spans or link destinations), or null when it has no letter.
367function replyFlow(blocks: Block[], s: Settings): boolean | null {
368 const parts: string[] = []
369 const collect = (list: Block[]): void => {
370 for (const b of list) {
371 if (b.kind === 'para') parts.push(parseInline(b.text).filter(r => !r.isolate).map(r => r.text).join(''))
372 else if (b.kind === 'list') for (const item of b.items) collect(item.blocks)
373 else if (b.kind === 'quote') collect(b.blocks)
374 }
375 }
376 collect(blocks)
377 const text = parts.join(' ')
378 if (!/\p{L}/u.test(text)) return null
379 return (resolveParagraph(text, null, s.base, s.share).level & 1) === 1
380}
381
382function merge(spans: Span[]): Span[] {
383 const out: Span[] = []
384 for (const sp of spans) {
385 if (!sp.text) continue
386 const prev = out[out.length - 1]
387 if (prev && prev.style === sp.style) prev.text += sp.text
388 else out.push({ ...sp })
389 }
390 return out
391}
392
393// A block the engine draws itself: one without RTL text (fences always go to `Code`).
394function isNative(b: Block): boolean {
395 return b.kind !== 'space' && b.kind !== 'code' && !hasRtl(b.source)
396}
397
398// A fence's rows at `indent` cells, `inset` cells kept free on the right: `Code` leaves, and in
399// visual order the rows of its lines with RTL text (code.ts), drawn left-aligned after the indent.
400// The reply bullet goes on the first row.
401type CodeBlock = Extract<Block, { kind: 'code' }>
402
403function codeRows(b: CodeBlock, indent: number, bullet: boolean, width: number, s: Settings, inset = 0): RowModel[] {
404 // a fence the scanner cannot be sure of, or with a cluster wider than the room, is drawn whole by
405 // the engine's `Code`, as Claude Code draws it; the rest of the reply is still the mod's
406 const parts = (s.mode === 'visual' && hasRtl(b.text) ? codeParts(b.text, b.language, width - indent - inset, s) : null) ?? [{ kind: 'native' as const, text: b.text }]
407 const rows: RowModel[] = []
408 for (const part of parts) {
409 if (part.kind === 'native') {
410 for (const source of chunks(part.text)) {
411 rows.push({ kind: 'code', language: b.language, source, indent, bullet, ...(inset ? { inset } : {}) })
412 bullet = false
413 }
414 continue
415 }
416 for (const spans of part.rows) {
417 const lead: Span[] = bullet ? [{ text: '●', style: BULLET }, { text: ' '.repeat(indent - 1), style: 0 }] : [{ text: ' '.repeat(indent), style: 0 }]
418 rows.push({ kind: 'text', spans, rtl: false, lead: lead.filter(sp => sp.text), paint: true })
419 bullet = false
420 }
421 }
422 return rows
423}
424
425// A Code leaf holds at most LEAF_MAX characters: longer sources are cut at line boundaries into
426// several leaves, drawn one under the other. A single longer line cannot be drawn this way.
427// Display controls other than tab and newline are dropped first (the engine refuses them in a leaf),
428// as in prose (layout.ts cleanText).
429function chunks(raw: string): string[] {
430 const source = cleanText(raw)
431 if (source.length <= LEAF_MAX) return [source]
432 const out: string[] = []
433 let cur = ''
434 for (const line of source.split('\n')) {
435 if (line.length > LEAF_MAX) throw new NoFit()
436 if (cur && cur.length + 1 + line.length > LEAF_MAX) {
437 out.push(cur)
438 cur = line
439 } else cur = cur ? cur + '\n' + line : line
440 }
441 out.push(cur)
442 return out
443}
444
445// A user prompt row: drawn literally as Claude Code draws it (no markdown). Each source line is its
446// own paragraph (a newline in a prompt separates paragraphs, as UAX #9 treats it). Two cells on
447// the left hold the `❯` of the first row, as in the composer.
448export type UserModel = { width: number; content: number; rows: { spans: Span[]; rtl: boolean; first: boolean }[] }
449
450export const USER_PREFIX = 2
451
452export function modelUser(text: string, columns: number, s: Settings): UserModel | null {
453 return modelLines(text, columns, USER_PREFIX, s, line => [{ text: line, style: 0 }])
454}
455
456// A slash command's output row (the summary `/recap` prints): five cells on the left hold the
457// engine's ` ⎿ `, then the text. Each source line is its own paragraph, with its inline styles.
458export const COMMAND_PREFIX = 5
459
460export function modelCommand(text: string, columns: number, s: Settings): UserModel | null {
461 return modelLines(text, columns, COMMAND_PREFIX, s, line => {
462 const runs = parseInline(line)
463 return runs.length ? runs : [{ text: '', style: 0 }]
464 })
465}
466
467function modelLines(text: string, columns: number, prefix: number, s: Settings, runsOf: (line: string) => Run[]): UserModel | null {
468 const width = columns - 1
469 const content = width - prefix - 1
470 if (!(content >= 1)) return null
471 const rows: UserModel['rows'] = []
472 for (const line of text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '').split('\n')) {
473 const laid = layoutParagraph(runsOf(line), { width: content, base: s.base, share: s.share, mode: s.mode, arabic: s.arabic })
474 if (laid === null) return null
475 for (const r of laid) rows.push({ spans: r.spans, rtl: r.rtl, first: rows.length === 0 })
476 }
477 return { width, content, rows }
478}
479
480// A bounded memo: re-renders of an unchanged block (scrolling, a redraw of a neighbour) and the
481// repeated prefixes of a streaming reply are common; width changes and new text miss. Bounded by
482// total key length as well as entry count, since a streaming reply's prefixes are long.
483export class Memo<V> {
484 private map = new Map<string, V>()
485 private chars = 0
486 private limit: number
487 private charLimit: number
488 constructor(limit = 200, charLimit = 2_000_000) {
489 this.limit = limit
490 this.charLimit = charLimit
491 }
492 get(key: string, make: () => V): V {
493 const hit = this.map.get(key)
494 if (hit !== undefined || this.map.has(key)) {
495 this.map.delete(key)
496 this.map.set(key, hit as V)
497 return hit as V
498 }
499 const v = make()
500 this.map.set(key, v)
501 this.chars += key.length
502 while (this.map.size > this.limit || this.chars > this.charLimit) {
503 const oldest = this.map.keys().next().value!
504 this.map.delete(oldest)
505 this.chars -= oldest.length
506 }
507 return v
508 }
509}
510
511// Rows of top-level blocks, shared by every message (see modelAssistant). After the class: a
512// class is not hoisted.
513const blockMemo = new Memo<RowModel[] | null>(2000, 4_000_000)
514src/options.ts 20 lines1// The mod's options (plugin.json `userConfig`), read once per load.
2
3import type { Base } from './bidi.ts'
4import type { Arabic, Mode } from './layout.ts'
5import type { Settings } from './render.ts'
6
7export type Order = Mode | 'off'
8
9export function settingsFrom(options: Record<string, unknown>): { order: Order; settings: Settings; textColor: string; recap: boolean; preview: boolean; suggestion: boolean } {
10 const order = (['visual', 'logical', 'off'].includes(String(options.order)) ? options.order : 'visual') as Order
11 const base = (options.direction === 'first-strong' ? 'first-strong' : 'rtl-share') as Base
12 const textColor = typeof options.textColor === 'string' ? options.textColor : ''
13 const arabic: Arabic = options.arabic === 'forms' ? 'forms' : 'letters'
14 const recap = options.recap === 'on'
15 // `on`: the draft and Claude Code's suggestion; `draft`: the draft alone
16 const preview = options.preview !== 'off'
17 const suggestion = preview && options.preview !== 'draft'
18 return { order, settings: { mode: order === 'logical' ? 'logical' : 'visual', base, share: 0.3, arabic }, textColor, recap, preview, suggestion }
19}
20src/preview.ts 224 lines1// The prompt preview. Claude Code's prompt box shows what is typed, and its dim suggestion (Tab
2// takes it), in logical order, so RTL text reads backwards there; a mod cannot redraw the box. With the
3// option `preview` on, the band above the prompt shows the same text laid out: the draft, or in an
4// empty box the suggestion. Only a text with RTL letters is shown. Display only: the box, the
5// suggestion and the prompt that is sent are never changed (docs/design/how-it-works.md).
6
7import { hasRtl, resolveParagraph } from './bidi.ts'
8import { layoutParagraph, type Span } from './layout.ts'
9import { DIM, ITALIC } from './markdown.ts'
10import type { Settings } from './render.ts'
11import { graphemes, textWidth } from './width.ts'
12
13// A row of the band above the prompt: its spans in visual order, its side, and the one-cell mark in
14// the two-cell column on that side (a space when none).
15export type BandRow = { spans: Span[]; rtl: boolean; mark: string }
16export const BAND_PREFIX = 2
17
18// The draft is marked as being written; the suggestion as a next prompt on offer. Not the Tab key:
19// the mod sees the suggestion arrive but not every way it leaves the box, so the band does not
20// promise what Tab inserts.
21export const DRAFT_MARK = '✎'
22export const GHOST_MARK = '»'
23// the top row when the draft's first rows are left out
24export const CUT_MARK = '…'
25// At most this many rows: the last ones, where typing goes on.
26export const PREVIEW_ROWS = 8
27// A draft longer than this is not laid out at each key; the band says so in one row.
28export const PREVIEW_MAX = 4000
29export const TOO_LONG = 'rtl: too long to preview'
30// While the box holds text, it is read this often, for the changes that raise no event.
31export const POLL_MS = 400
32
33// The rows of the box's text in `columns` cells (the mark column included), at most `maxRows`.
34// Null when there is nothing to show: no RTL letters, or no room (for the too-long note as well).
35export function modelPreview(text: string, ghost: boolean, columns: number, maxRows: number, s: Settings): BandRow[] | null {
36 const limit = Math.min(maxRows, PREVIEW_ROWS)
37 const content = columns - BAND_PREFIX
38 if (!hasRtl(text) || limit < 1 || !(content >= 8)) return null
39 const mark = ghost ? GHOST_MARK : DRAFT_MARK
40 if (text.length > PREVIEW_MAX) return textWidth(TOO_LONG) <= content ? [{ spans: [{ text: TOO_LONG, style: DIM | ITALIC }], rtl: false, mark }] : null
41 const style = ghost ? DIM | ITALIC : 0
42 const rows: BandRow[] = []
43 // each line of the draft is a paragraph of its own, in its own direction
44 for (const line of text.split('\n')) {
45 if (line === '') {
46 rows.push({ spans: [], rtl: false, mark: ' ' })
47 continue
48 }
49 const rtl = (resolveParagraph(line, null, s.base, s.share).level & 1) === 1
50 const laid = layoutParagraph([{ text: line, style }], { width: content, base: rtl ? 'rtl' : 'ltr', share: s.share, mode: s.mode, arabic: s.arabic })
51 if (laid === null) return null
52 for (const r of laid) rows.push({ spans: r.spans, rtl, mark: ' ' })
53 }
54 if (rows.length <= limit) return [{ ...rows[0], mark }, ...rows.slice(1)]
55 const tail = rows.slice(-limit)
56 return [{ ...tail[0], mark: CUT_MARK }, ...tail.slice(1)]
57}
58
59// The draft pane (`/rtl-draft`): the box's text laid out like the band, every row of it, and a bar
60// where the cursor stands. The bar is a neutral character put into the text at the cursor (UTF-16
61// units, as the box counts them, moved back to a cluster's start), so it lands where UAX #9 puts
62// that spot; it is a run of its own style, CURSOR_STYLE, so a bar typed in the draft stays text. A
63// line without letters (an empty draft too) is drawn on the RTL side. Null when the pane is too
64// narrow; past PREVIEW_MAX the one row says so.
65export const CURSOR = '\u2502'
66export const CURSOR_STYLE = 1024
67export type DraftModel = { rows: BandRow[]; cursorRow: number }
68export function modelDraft(text: string, cursor: number, columns: number, s: Settings): DraftModel | null {
69 if (!(columns >= 8)) return null
70 if (text.length > PREVIEW_MAX) return textWidth(TOO_LONG) <= columns ? { rows: [{ spans: [{ text: TOO_LONG, style: DIM | ITALIC }], rtl: false, mark: ' ' }], cursorRow: 0 } : null
71 let at = 0
72 for (const g of graphemes(text)) {
73 if (at + g.length > cursor) break
74 at += g.length
75 }
76 // the cursor's line, and where in it
77 const before = text.slice(0, at)
78 const cursorLine = before.split('\n').length - 1
79 const column = before.length - (before.lastIndexOf('\n') + 1)
80 const rows: BandRow[] = []
81 let cursorRow = 0
82 const lines = text.split('\n')
83 for (let index = 0; index < lines.length; index++) {
84 const line = lines[index]
85 const runs = index === cursorLine
86 ? [{ text: line.slice(0, column), style: 0 }, { text: CURSOR, style: CURSOR_STYLE }, { text: line.slice(column), style: 0 }].filter(r => r.text !== '')
87 : [{ text: line, style: 0 }]
88 if (line === '' && index !== cursorLine) {
89 rows.push({ spans: [], rtl: true, mark: ' ' })
90 continue
91 }
92 const rtl = !/\p{L}/u.test(line) || (resolveParagraph(line, null, s.base, s.share).level & 1) === 1
93 const laid = layoutParagraph(runs, { width: columns, base: rtl ? 'rtl' : 'ltr', share: s.share, mode: s.mode, arabic: s.arabic })
94 // one paragraph that cannot be laid out fails the whole draft (the caller falls back)
95 if (laid === null) return null
96 for (const r of laid) {
97 if (r.spans.some(sp => sp.style & CURSOR_STYLE)) cursorRow = rows.length
98 rows.push({ spans: r.spans, rtl, mark: ' ' })
99 }
100 }
101 return rows.length ? { rows, cursorRow } : null
102}
103
104// What the band knows of the box, kept out of the hooks so these rules are tested on their own: the
105// draft as last seen (an edit's result, or the box read again), Claude Code's suggestion while it
106// stays on offer, and the edits under way. Observed live on 2.1.289: the suggestion shows again
107// whenever an edit empties the box (typing then deleting, Ctrl+U, even after Tab took it), and is
108// dropped when the box is sent (a slash command too, which raises no prompt.submit) or a turn starts.
109// The mod cannot see the suggestion itself, so a box that empties with no edit to empty it is taken
110// as sent, and the suggestion dropped: an inference that errs toward showing nothing (going back
111// down through history to an empty box looks the same).
112export class BoxMirror {
113 draft = ''
114 // where the cursor stood in the draft last seen (UTF-16 units)
115 cursor = 0
116 ghost: string | null = null
117 // the hint line's last word on the box: empty or holding text
118 holding = false
119 private editing = 0
120 private edits = 0
121 private offers = 0
122 // whether the box last became empty by an edit (then Claude Code shows the suggestion again)
123 private emptiedByEdit = false
124 // a send, a new or ended session: what was read or edited before it is not taken after it
125 private generation = 0
126 // reads of the box, in the order they began: an older one answered late is not taken
127 private reads = 0
128 private lastRead = 0
129
130 // Whether the band shows the box: what it shows holds RTL letters.
131 shows(): boolean {
132 return this.draft !== '' ? hasRtl(this.draft) : this.ghost !== null && hasRtl(this.ghost)
133 }
134
135 // An edit begins; it ends with the box's new text (null when the edit failed).
136 editStart(): number {
137 this.editing++
138 this.edits++
139 return this.generation
140 }
141 editEnd(generation: number, text: string | null, cursor = text?.length ?? 0): void {
142 this.editing--
143 if (text === null || generation !== this.generation) return
144 this.draft = text
145 this.cursor = cursor
146 this.emptiedByEdit = text === ''
147 }
148
149 // A suggestion is about to be answered; true when it shows and no later one was asked since.
150 offer(): number {
151 return ++this.offers
152 }
153 suggested(offer: number, text: string): boolean {
154 if (offer !== this.offers) return false
155 // shown only in an empty box: a draft last seen is gone
156 this.draft = ''
157 this.cursor = 0
158 this.ghost = text
159 return true
160 }
161
162 // The box sent: no suggestion on offer any more, and nothing from before is taken.
163 sent(): void {
164 this.draft = ''
165 this.cursor = 0
166 this.emptiedByEdit = false
167 this.generation++
168 this.dropGhost()
169 }
170 // A session began or ended: as sent, and the hint line's word is not known yet.
171 reset(): void {
172 this.sent()
173 this.holding = false
174 }
175 // A turn began: Claude Code drops its suggestion.
176 dropGhost(): void {
177 this.ghost = null
178 this.offers++
179 }
180
181 // The hint line drawn again: true when it turned (empty to holding text, or back).
182 hint(holding: boolean): boolean {
183 if (holding === this.holding) return false
184 this.holding = holding
185 if (holding) this.emptiedByEdit = false
186 else this.emptied()
187 return true
188 }
189
190 // The box read again (`$.prompt.read`): `token` from `readStart` when the read began. True when the
191 // band or the draft pane has something new (the pane draws the cursor too).
192 readStart(): ReadToken {
193 return { edits: this.edits, generation: this.generation, read: ++this.reads }
194 }
195 read(token: ReadToken, text: string, cursor = text.length): boolean {
196 if (!this.current(token)) return false
197 this.lastRead = token.read
198 const moved = cursor !== this.cursor
199 this.cursor = cursor
200 if (text === this.draft) return moved
201 if (text === '') this.emptied()
202 else {
203 this.draft = text
204 this.emptiedByEdit = false
205 }
206 return true
207 }
208
209 // Whether a read that began with `token` still tells the box as it is: no edit, send, new session
210 // or later read has happened since (an edit under way may change it yet).
211 current(token: ReadToken): boolean {
212 return token.edits === this.edits && token.generation === this.generation && token.read >= this.lastRead && this.editing === 0
213 }
214
215 // The box is empty now. With no edit to empty it, it is taken as sent: the suggestion is dropped.
216 private emptied(): void {
217 if (this.editing === 0 && !this.emptiedByEdit) this.dropGhost()
218 this.draft = ''
219 this.cursor = 0
220 }
221}
222
223export type ReadToken = { edits: number; generation: number; read: number }
224src/recap.ts 198 lines1// The recap band: an opt-in stand-in for Claude Code's automatic recap (`※ recap: …`), which a mod
2// cannot draw (docs/design/how-it-works.md). It waits for the engine's own idle notification, then
3// three minutes more, asks the session's model for the summary the native recap asks for, and
4// shows it above the prompt. Nothing is stored. This module is the part without engine calls: the
5// gate that decides when, the summary's text, and the rows.
6
7import { resolveParagraph } from './bidi.ts'
8import { layoutParagraph, type Run, type Span } from './layout.ts'
9import { BOLD, DIM, ITALIC } from './markdown.ts'
10import type { Settings } from './render.ts'
11import { ARABIC } from './shape.ts'
12
13// The native recap's instruction, word for word (Claude Code 2.1.289).
14export const RECAP_PROMPT =
15 'The user stepped away and is coming back. Recap in under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents.'
16
17// What the band asks: the native instruction and one sentence more, since without it a recap of an
18// Arabic or Persian conversation often came back in English.
19export const RECAP_ASK = `${RECAP_PROMPT} Write it in the language of the user's own messages.`
20
21// After the idle notification, as long as the native recap waits after a turn by default.
22export const RECAP_DELAY_MS = 180_000
23// The native recap's cap on the summary, in UTF-16 code units, the ellipsis included.
24export const RECAP_MAX = 400
25// Shown after the first few recaps, as the native recap shows `(disable recaps in /config)`.
26export const RECAP_HINT = ' (turn off: /config, Recap band)'
27const HINT_COUNT = 3
28// Prompts the person must have sent: before the first recap, and since the last one.
29const FIRST_PROMPTS = 3
30const MORE_PROMPTS = 2
31
32// The summary cut as the native recap cuts it: at most RECAP_MAX - 1 code units, back to a space
33// when that keeps more than half, then an ellipsis.
34export function capRecap(text: string): string {
35 const t = text.trim()
36 if (t.length <= RECAP_MAX) return t
37 let cut = RECAP_MAX - 1
38 // never between the halves of a surrogate pair
39 if (/[\uD800-\uDBFF]/.test(t[cut - 1])) cut--
40 const space = t.lastIndexOf(' ', cut)
41 if (space > (RECAP_MAX - 1) / 2) cut = space
42 return t.slice(0, cut).trimEnd() + '…'
43}
44
45// What the session keeps across a reload of the mod (`$.state`): the counts behind the prompts rule
46// and the hint, for the session they belong to.
47export type Counts = { prompts: number; promptsAtRecap: number; recaps: number }
48export type RecapCounts = Counts & { session: string }
49
50// When a recap may be made. The engine's events drive it; every method is cheap and pure, so the
51// hooks stay thin and the rules are tested here.
52// - `revision` changes with every submitted prompt, main-loop turn, session start or end and reload:
53// a recap belongs to one, and is void once it changes. It never goes back, so a wait from before
54// a reset cannot match a later one.
55// - The idle notification arms one timer for the revision it came in; any sign of the person, a
56// prompt, a new turn or a new session disarms it, and that revision gets no recap.
57export class RecapGate {
58 revision = 0
59 prompts = 0
60 private promptsAtRecap = 0
61 private recaps = 0
62 private busy = false
63 // the revision whose completed main turn the idle notification may arm (none until one completes
64 // after a start or reload: the baseline)
65 private completed = -1
66 // the revision a timer is armed for, or a fork is running for
67 private armed = -1
68 private touched = -1
69 // the last revision a recap was tried for: one try each
70 private tried = -1
71 // Each start or end of a session (or load of the mod) begins a new epoch: a restore or a save
72 // asked in an older one is dropped. While `loading`, the session's kept counts are on their way:
73 // prompts are counted, but no recap is made until the counts are whole (a recap made meanwhile
74 // would set its baseline on part of them). A read that never answers leaves the session without.
75 epoch = 0
76 loading = false
77
78 // A prompt was submitted, from any origin, whether or not it enters: what waits is void.
79 submit(): void {
80 this.revision++
81 this.armed = -1
82 }
83
84 // The person's own prompt entered the conversation (not dropped): it counts.
85 prompt(): void {
86 this.prompts++
87 }
88
89 // A main-loop turn started or ended. (`$.model.fork` raises neither, seen on 2.1.289.)
90 turnStart(): void {
91 this.busy = true
92 this.revision++
93 this.armed = -1
94 }
95
96 turnEnd(): void {
97 this.busy = false
98 this.completed = this.revision
99 }
100
101 // The person did something the mod can see (an edit or a cursor move at the prompt), or a setting
102 // of the band changed.
103 touch(): void {
104 this.touched = this.revision
105 this.armed = -1
106 }
107
108 // A session started (`loading`: its kept counts are to be read) or ended, or the mod loaded
109 // again: what waits is void, the counts start over, and no recap until a main turn completes after
110 // this. A session ended by /clear or a resume goes on as another conversation, or one whose counts
111 // the mod cannot read (a resume raises no session.start), so nothing carries over.
112 reset(loading: boolean): void {
113 this.revision++
114 this.armed = -1
115 this.busy = false
116 this.completed = -1
117 this.epoch++
118 this.loading = loading
119 this.prompts = 0
120 this.promptsAtRecap = 0
121 this.recaps = 0
122 }
123
124 // The counts, to keep in the session.
125 counts(): Counts {
126 return { prompts: this.prompts, promptsAtRecap: this.promptsAtRecap, recaps: this.recaps }
127 }
128 // The session's kept counts, read in `epoch`: added to what was counted while they were read, and
129 // only when they are this session's and no start or end came in between. True when taken.
130 restore(kept: RecapCounts | undefined, session: string, epoch: number): boolean {
131 if (epoch !== this.epoch) return false
132 this.loading = false
133 if (kept && kept.session === session) {
134 this.prompts += kept.prompts
135 this.promptsAtRecap += kept.promptsAtRecap
136 this.recaps += kept.recaps
137 }
138 return true
139 }
140
141 // The engine's idle notification: whether to arm the timer now.
142 idle(): boolean {
143 if (this.loading || this.busy || this.completed !== this.revision || this.touched === this.revision || this.tried === this.revision) return false
144 if (!this.due()) return false
145 this.armed = this.revision
146 this.tried = this.revision
147 return true
148 }
149
150 // Whether the armed timer of `revision` may still make its recap.
151 live(revision: number): boolean {
152 return this.armed === revision && this.revision === revision && !this.loading && !this.busy && this.touched !== revision
153 }
154
155 private due(): boolean {
156 return this.recaps === 0 ? this.prompts >= FIRST_PROMPTS : this.prompts - this.promptsAtRecap >= MORE_PROMPTS
157 }
158
159 // The fork answered and its recap is shown: whether it carries the hint.
160 shown(): boolean {
161 this.recaps++
162 this.promptsAtRecap = this.prompts
163 this.armed = -1
164 return this.recaps <= HINT_COUNT
165 }
166
167 // The fork gave no recap: this revision gets none (the native recap retries a failed one only
168 // within its turn; one attempt per turn here).
169 failed(): void {
170 this.armed = -1
171 }
172}
173
174// The band's rows: `※` in a two-cell column on the row's start side, then a bold dim `recap: ` and
175// the summary in dim italic, as the native row draws them (Arabic-script text dim only: a slanted
176// Arabic letter can read as another). The summary's own text decides the direction; an RTL recap
177// runs from the right, its rows in visual order. The hint is a paragraph of its own, left to right
178// on the summary's side: wrapped inside an RTL row its brackets would read turned around.
179export type RecapModel = { width: number; content: number; rtl: boolean; rows: { spans: Span[]; first: boolean }[] }
180
181export const RECAP_PREFIX = 2
182
183export function modelRecap(text: string, hint: boolean, columns: number, s: Settings): RecapModel | null {
184 const width = columns
185 const content = width - RECAP_PREFIX
186 if (!(content >= 8)) return null
187 const rtl = (resolveParagraph(text, null, s.base, s.share).level & 1) === 1
188 const runs: Run[] = [
189 { text: 'recap: ', style: BOLD | DIM },
190 { text, style: ARABIC.test(text) ? DIM : ITALIC | DIM },
191 ]
192 const lay = (r: Run[], base: 'rtl' | 'ltr') => layoutParagraph(r, { width: content, base, share: s.share, mode: s.mode, arabic: s.arabic })
193 const laid = lay(runs, rtl ? 'rtl' : 'ltr')
194 const hinted = hint ? lay([{ text: RECAP_HINT.trim(), style: ITALIC | DIM }], 'ltr') : []
195 if (laid === null || hinted === null) return null
196 return { width, content, rtl, rows: [...laid, ...hinted].map((r, i) => ({ spans: r.spans, first: i === 0 })) }
197}
198src/width.ts 105 lines1// Terminal cell widths per grapheme cluster, matching what Claude Code's renderer measures.
2//
3// The renderer emits one row per visual line and must never hand Ink a row wider than its box
4// (Ink would wrap it a second time). Claude Code 2.1.288 measures text with
5// `Bun.stringWidth(text, { ambiguousIsNarrow: true })` of its embedded Bun 1.4.3. That function is
6// not reachable from the hooks VM, so this module reproduces it:
7// - per scalar: the wide ranges below (East Asian Wide/Fullwidth and emoji presentation, taken from
8// Bun 1.3.14's table, the closest public release, plus the late emoji blocks) are 2 cells;
9// nonspacing and enclosing marks (Mn, Me), format and control characters, default-ignorables,
10// and the zero ranges below (Bun's zero-width spacing marks, Hangul medial/final jamo, some
11// unassigned Indic points) are 0; everything else, ambiguous-width included, is 1;
12// - per cluster: an emoji sequence (VS16, keycap, a regional-indicator pair, a skin-tone modifier,
13// a ZWJ sequence, a tag sequence) is 2; any other cluster is the sum of its scalars.
14// Checked cell by cell against the live 2.1.288 renderer (tests/fixtures/ink-widths-2.1.288.json); where Bun
15// 1.4.3 differs from 1.3.14 the measurement wins (Hebrew and Arabic points are 0, Hangul fillers,
16// newer emoji).
17
18const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
19
20export function graphemes(text: string): string[] {
21 const out: string[] = []
22 for (const s of segmenter.segment(text)) out.push(s.segment)
23 return out
24}
25
26// Inclusive [start, end] pairs, sorted.
27const WIDE = [
28 0x1100, 0x115f, 0x20e3, 0x20e3, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec, 0x23f0, 0x23f0,
29 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x2648, 0x2653, 0x267f, 0x267f, 0x2693, 0x2693,
30 0x26a1, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4,
31 0x26ea, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd, 0x2705, 0x2705,
32 0x270a, 0x270b, 0x2728, 0x2728, 0x274c, 0x274c, 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757,
33 0x2795, 0x2797, 0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50, 0x2b55, 0x2b55,
34 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0, 0x303e, 0x3041, 0x3096, 0x3099, 0x30ff,
35 0x3105, 0x312f, 0x3131, 0x318e, 0x3190, 0x31e3, 0x31ef, 0x321e, 0x3220, 0x3247, 0x3250, 0x4dbf,
36 0x4e00, 0xa48c, 0xa490, 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19,
37 0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4,
38 0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800, 0x18cd5, 0x18d00, 0x18d08, 0x1aff0, 0x1aff3,
39 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132, 0x1b150, 0x1b152,
40 0x1b155, 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf,
41 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a, 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248,
42 0x1f250, 0x1f251, 0x1f260, 0x1f265, 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c,
43 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4,
44 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e,
45 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f,
46 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6df, 0x1f6eb, 0x1f6ec,
47 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945,
48 0x1f947, 0x1f9ff, 0x1fa70, 0x1faff, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
49]
50
51const ZERO = [
52 0x93b, 0x93b, 0x93e, 0x940, 0x949, 0x94c, 0x980, 0x980, 0x982, 0x982, 0x9ba, 0x9bb, 0x9be, 0x9c0,
53 0x9c5, 0x9cc, 0x9d1, 0x9d7, 0xa00, 0xa00, 0xa3a, 0xa3b, 0xa3e, 0xa40, 0xa43, 0xa46, 0xa49, 0xa4a,
54 0xa52, 0xa57, 0xa62, 0xa63, 0xa80, 0xa80, 0xaba, 0xabb, 0xabe, 0xac0, 0xac6, 0xac6, 0xac9, 0xacc,
55 0xad1, 0xad7, 0xb00, 0xb00, 0xb02, 0xb02, 0xb3a, 0xb3b, 0xb3e, 0xb3e, 0xb40, 0xb40, 0xb45, 0xb4c,
56 0xb51, 0xb54, 0xb57, 0xb57, 0xb80, 0xb81, 0xbba, 0xbbc, 0xbbe, 0xbbf, 0xbc1, 0xbcc, 0xbd1, 0xbd7,
57 0xbe2, 0xbe3, 0xc01, 0xc02, 0xc3a, 0xc3b, 0xc41, 0xc45, 0xc49, 0xc49, 0xc51, 0xc54, 0xc57, 0xc57,
58 0xc80, 0xc80, 0xc82, 0xc82, 0xcba, 0xcbb, 0xcbe, 0xcbe, 0xcc0, 0xcc5, 0xcc7, 0xccb, 0xcd1, 0xcd7,
59 0xd02, 0xd02, 0xd3a, 0xd3a, 0xd3e, 0xd40, 0xd45, 0xd4c, 0x1160, 0x11ff, 0x1acf, 0x1aff,
60 0x20f1, 0x20ff, 0xd7b0, 0xd7ff,
61]
62
63const MARK = /^[\p{Mn}\p{Me}\p{Cf}\p{Cc}\p{Default_Ignorable_Code_Point}]$/u
64const EMOJI_PRESENTATION = /^\p{Emoji_Presentation}$/u
65const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
66const MODIFIER = /[\u{1F3FB}-\u{1F3FF}]/u
67const TAG = /[\u{E0020}-\u{E007F}]/u
68const REGIONAL_PAIR = /^[\u{1F1E6}-\u{1F1FF}]{2}$/u
69
70function inRanges(table: number[], cp: number): boolean {
71 let lo = 0
72 let hi = table.length / 2 - 1
73 while (lo <= hi) {
74 const mid = (lo + hi) >> 1
75 if (cp < table[mid * 2]) hi = mid - 1
76 else if (cp > table[mid * 2 + 1]) lo = mid + 1
77 else return true
78 }
79 return false
80}
81
82export function scalarWidth(cp: number): number {
83 if (cp === 0x115f) return 1 // the Hangul choseong filler: measured 1, although default-ignorable
84 if (cp === 0x20e3) return 2 // a lone combining keycap: measured 2
85 const ch = String.fromCodePoint(cp)
86 if (MARK.test(ch) || inRanges(ZERO, cp)) return 0
87 if (inRanges(WIDE, cp) || (EMOJI_PRESENTATION.test(ch) && !(cp >= 0x1f1e6 && cp <= 0x1f1ff))) return 2 // a lone regional indicator is 1
88 return 1
89}
90
91export function clusterWidth(cluster: string): number {
92 if (cluster.length === 0) return 0
93 const multi = cluster.length > String.fromCodePoint(cluster.codePointAt(0)!).length
94 if (multi && (cluster.includes('\uFE0F') || cluster.includes('\u20E3') || REGIONAL_PAIR.test(cluster) || TAG.test(cluster) || (PICTOGRAPHIC.test(cluster) && (cluster.includes('\u200D') || MODIFIER.test(cluster))))) return 2
95 let w = 0
96 for (const ch of cluster) w += scalarWidth(ch.codePointAt(0)!)
97 return w
98}
99
100export function textWidth(text: string): number {
101 let w = 0
102 for (const g of graphemes(text)) w += clusterWidth(g)
103 return w
104}
105vendor/bidi.mjs 1047 lines1// bidi-js 1.1.0 (https://github.com/lojjic/bidi-js), MIT License, Copyright (c) 2021 Jason Johnston.
2// Vendored unmodified from the npm package dist/bidi.mjs; see vendor/LICENSE-bidi-js.txt.
3function bidiFactory() {
4var bidi = (function (exports) {
5
6 // Bidi character types data, auto generated
7 var DATA = {
8 "R": "13k,1a,2,3,3,2+1j,ch+16,a+1,5+2,2+n,5,a,4,6+16,4+3,h+1b,4mo,179q,2+9,2+11,2i9+7y,2+68,4,3+4,5+13,4+3,2+4k,3+29,8+cf,1t+7z,w+17,3+3m,1t+3z,16o1+5r,8+30,8+mc,29+1r,29+4v,75+73",
9 "EN": "1c+9,3d+1,6,187+9,513,4+5,7+9,sf+j,175h+9,qw+q,161f+1d,4xt+a,25i+9",
10 "ES": "17,2,6dp+1,f+1,av,16vr,mx+1,4o,2",
11 "ET": "z+2,3h+3,b+1,ym,3e+1,2o,p4+1,8,6u,7c,g6,1wc,1n9+4,30+1b,2n,6d,qhx+1,h0m,a+1,49+2,63+1,4+1,6bb+3,12jj",
12 "AN": "16o+5,2j+9,2+1,35,ed,1ff2+9,87+u",
13 "CS": "18,2+1,b,2u,12k,55v,l,17v0,2,3,53,2+1,b",
14 "B": "a,3,f+2,2v,690",
15 "S": "9,2,k",
16 "WS": "c,k,4f4,1vk+a,u,1j,335",
17 "ON": "x+1,4+4,h+5,r+5,r+3,z,5+3,2+1,2+1,5,2+2,3+4,o,w,ci+1,8+d,3+d,6+8,2+g,39+1,9,6+1,2,33,b8,3+1,3c+1,7+1,5r,b,7h+3,sa+5,2,3i+6,jg+3,ur+9,2v,ij+1,9g+9,7+a,8m,4+1,49+x,14u,2+2,c+2,e+2,e+2,e+1,i+n,e+e,2+p,u+2,e+2,36+1,2+3,2+1,b,2+2,6+5,2,2,2,h+1,5+4,6+3,3+f,16+2,5+3l,3+81,1y+p,2+40,q+a,m+13,2r+ch,2+9e,75+hf,3+v,2+2w,6e+5,f+6,75+2a,1a+p,2+2g,d+5x,r+b,6+3,4+o,g,6+1,6+2,2k+1,4,2j,5h+z,1m+1,1e+f,t+2,1f+e,d+3,4o+3,2s+1,w,535+1r,h3l+1i,93+2,2s,b+1,3l+x,2v,4g+3,21+3,kz+1,g5v+1,5a,j+9,n+v,2,3,2+8,2+1,3+2,2,3,46+1,4+4,h+5,r+5,r+a,3h+2,4+6,b+4,78,1r+24,4+c,4,1hb,ey+6,103+j,16j+c,1ux+7,5+g,fsh,jdq+1t,4,57+2e,p1,1m,1m,1m,1m,4kt+1,7j+17,5+2r,d+e,3+e,2+e,2+10,m+4,w,1n+5,1q,4z+5,4b+rb,9+c,4+c,4+37,d+2g,8+b,l+b,5+1j,9+9,7+13,9+t,3+1,27+3c,2+29,2+3q,d+d,3+4,4+2,6+6,a+o,8+6,a+2,e+6,16+42,2+1i",
18 "BN": "0+8,6+d,2s+5,2+p,e,4m9,1kt+2,2b+5,5+5,17q9+v,7k,6p+8,6+1,119d+3,440+7,96s+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+75,6p+2rz,1ben+1,1ekf+1,1ekf+1",
19 "NSM": "lc+33,7o+6,7c+18,2,2+1,2+1,2,21+a,1d+k,h,2u+6,3+5,3+1,2+3,10,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,g+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+g,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,k1+w,2db+2,3y,2p+v,ff+3,30+1,n9x+3,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,r2,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+5,3+1,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2d+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,f0c+4,1o+6,t5,1s+3,2a,f5l+1,43t+2,i+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,gzhy+6n",
20 "AL": "16w,3,2,e+1b,z+2,2+2s,g+1,8+1,b+m,2+t,s+2i,c+e,4h+f,1d+1e,1bwe+dp,3+3z,x+c,2+1,35+3y,2rm+z,5+7,b+5,dt+l,c+u,17nl+27,1t+27,4x+6n,3+d",
21 "LRO": "6ct",
22 "RLO": "6cu",
23 "LRE": "6cq",
24 "RLE": "6cr",
25 "PDF": "6cs",
26 "LRI": "6ee",
27 "RLI": "6ef",
28 "FSI": "6eg",
29 "PDI": "6eh"
30 };
31
32 var TYPES = {};
33 var TYPES_TO_NAMES = {};
34 TYPES.L = 1; //L is the default
35 TYPES_TO_NAMES[1] = 'L';
36 Object.keys(DATA).forEach(function (type, i) {
37 TYPES[type] = 1 << (i + 1);
38 TYPES_TO_NAMES[TYPES[type]] = type;
39 });
40 Object.freeze(TYPES);
41
42 var ISOLATE_INIT_TYPES = TYPES.LRI | TYPES.RLI | TYPES.FSI;
43 var STRONG_TYPES = TYPES.L | TYPES.R | TYPES.AL;
44 var NEUTRAL_ISOLATE_TYPES = TYPES.B | TYPES.S | TYPES.WS | TYPES.ON | TYPES.FSI | TYPES.LRI | TYPES.RLI | TYPES.PDI;
45 var BN_LIKE_TYPES = TYPES.BN | TYPES.RLE | TYPES.LRE | TYPES.RLO | TYPES.LRO | TYPES.PDF;
46 var TRAILING_TYPES = TYPES.S | TYPES.WS | TYPES.B | ISOLATE_INIT_TYPES | TYPES.PDI | BN_LIKE_TYPES;
47
48 var map = null;
49
50 function parseData () {
51 if (!map) {
52 //const start = performance.now()
53 map = new Map();
54 var start = 0;
55 for (var type in DATA) {
56 if (DATA.hasOwnProperty(type)) {
57 var segments = DATA[type];
58 var temp = '';
59 var end = (void 0);
60 var state = false;
61 var lastCode = 0;
62 for (var i = 0; i <= segments.length + 1; i += 1) {
63 var char = segments[i];
64 if (char !== ',' && i !== segments.length) {
65 if (char === '+') {
66 state = true;
67 lastCode = start = lastCode + parseInt(temp, 36);
68 temp = '';
69 } else {
70 temp += char;
71 }
72 } else {
73 if (!state) {
74 lastCode = start = lastCode + parseInt(temp, 36);
75 end = start;
76 } else {
77 end = start + parseInt(temp, 36);
78 }
79 state = false;
80 temp = '';
81 lastCode = end;
82 for (var j = start; j < end + 1; j += 1) {
83 map.set(j, TYPES[type]);
84 }
85 }
86 }
87 }
88 }
89 //console.log(`char types parsed in ${performance.now() - start}ms`)
90 }
91 }
92
93 /**
94 * @param {string} char
95 * @return {number}
96 */
97 function getBidiCharType (char) {
98 parseData();
99 return map.get(char.codePointAt(0)) || TYPES.L
100 }
101
102 /**
103 * Get Bidi Character Type Name
104 * @param {string} char
105 * @returns { "L" | "R" | "EN" | "ES" | "ET" | "AN" | "CS" | "B" | "S" | "WS" | "ON" | "BN" | "NSM" | "AL" | "LRO" | "RLO" | "LRE" | "RLE" | "PDF" | "LRI" | "RLI" | "FSI" | "PDI" }
106 */
107 function getBidiCharTypeName(char) {
108 return TYPES_TO_NAMES[getBidiCharType(char)]
109 }
110
111 // Bidi bracket pairs data, auto generated
112 var data$1 = {
113 "pairs": "14>1,1e>2,u>2,2wt>1,1>1,1ge>1,1wp>1,1j>1,f>1,hm>1,1>1,u>1,u6>1,1>1,+5,28>1,w>1,1>1,+3,b8>1,1>1,+3,1>3,-1>-1,3>1,1>1,+2,1s>1,1>1,x>1,th>1,1>1,+2,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,4q>1,1e>2,u>2,2>1,+1",
114 "canonical": "6f1>-6dx,6dy>-6dx,6ec>-6ed,6ee>-6ed,6ww>2jj,-2ji>2jj,14r4>-1e7l,1e7m>-1e7l,1e7m>-1e5c,1e5d>-1e5b,1e5c>-14qx,14qy>-14qx,14vn>-1ecg,1ech>-1ecg,1edu>-1ecg,1eci>-1ecg,1eda>-1ecg,1eci>-1ecg,1eci>-168q,168r>-168q,168s>-14ye,14yf>-14ye"
115 };
116
117 /**
118 * Parses an string that holds encoded codepoint mappings, e.g. for bracket pairs or
119 * mirroring characters, as encoded by scripts/generateBidiData.js. Returns an object
120 * holding the `map`, and optionally a `reverseMap` if `includeReverse:true`.
121 * @param {string} encodedString
122 * @param {boolean} includeReverse - true if you want reverseMap in the output
123 * @return {{map: Map<number, number>, reverseMap?: Map<number, number>}}
124 */
125 function parseCharacterMap (encodedString, includeReverse) {
126 var radix = 36;
127 var lastCode = 0;
128 var map = new Map();
129 var reverseMap = includeReverse && new Map();
130 var prevPair;
131 encodedString.split(',').forEach(function visit(entry) {
132 if (entry.indexOf('+') !== -1) {
133 for (var i = +entry; i--;) {
134 visit(prevPair);
135 }
136 } else {
137 prevPair = entry;
138 var ref = entry.split('>');
139 var a = ref[0];
140 var b = ref[1];
141 a = String.fromCodePoint(lastCode += parseInt(a, radix));
142 b = String.fromCodePoint(lastCode += parseInt(b, radix));
143 map.set(a, b);
144 includeReverse && reverseMap.set(b, a);
145 }
146 });
147 return { map: map, reverseMap: reverseMap }
148 }
149
150 var openToClose, closeToOpen, canonical;
151
152 function parse$1 () {
153 if (!openToClose) {
154 //const start = performance.now()
155 var ref = parseCharacterMap(data$1.pairs, true);
156 var map = ref.map;
157 var reverseMap = ref.reverseMap;
158 openToClose = map;
159 closeToOpen = reverseMap;
160 canonical = parseCharacterMap(data$1.canonical, false).map;
161 //console.log(`brackets parsed in ${performance.now() - start}ms`)
162 }
163 }
164
165 /**
166 * Get the opening bracket character corresponding to a given closing bracket character.
167 * @param {string} char
168 * @returns {string | null}
169 */
170 function openingToClosingBracket (char) {
171 parse$1();
172 return openToClose.get(char) || null
173 }
174
175 /**
176 * Get the closing bracket character corresponding to a given opening bracket character.
177 * @param {string} char
178 * @returns {string | null}
179 */
180 function closingToOpeningBracket (char) {
181 parse$1();
182 return closeToOpen.get(char) || null
183 }
184
185 /**
186 * Retrieves the canonical form of a bracket character.
187 * @param {string} char
188 * @returns {string | null}
189 */
190 function getCanonicalBracket (char) {
191 parse$1();
192 return canonical.get(char) || null
193 }
194
195 // Local type aliases
196 var TYPE_L = TYPES.L;
197 var TYPE_R = TYPES.R;
198 var TYPE_EN = TYPES.EN;
199 var TYPE_ES = TYPES.ES;
200 var TYPE_ET = TYPES.ET;
201 var TYPE_AN = TYPES.AN;
202 var TYPE_CS = TYPES.CS;
203 var TYPE_B = TYPES.B;
204 var TYPE_S = TYPES.S;
205 var TYPE_ON = TYPES.ON;
206 var TYPE_BN = TYPES.BN;
207 var TYPE_NSM = TYPES.NSM;
208 var TYPE_AL = TYPES.AL;
209 var TYPE_LRO = TYPES.LRO;
210 var TYPE_RLO = TYPES.RLO;
211 var TYPE_LRE = TYPES.LRE;
212 var TYPE_RLE = TYPES.RLE;
213 var TYPE_PDF = TYPES.PDF;
214 var TYPE_LRI = TYPES.LRI;
215 var TYPE_RLI = TYPES.RLI;
216 var TYPE_FSI = TYPES.FSI;
217 var TYPE_PDI = TYPES.PDI;
218
219 /**
220 * @typedef {object} GetEmbeddingLevelsResult
221 * @property {{start: number, end: number, level: number}[]} paragraphs
222 * @property {Uint8Array} levels
223 */
224
225 /**
226 * This function applies the Bidirectional Algorithm to a string, returning the resolved embedding levels
227 * in a single Uint8Array plus a list of objects holding each paragraph's start and end indices and resolved
228 * base embedding level.
229 *
230 * @param {string} string - The input string
231 * @param {"ltr"|"rtl"|"auto"} [baseDirection] - Use "ltr" or "rtl" to force a base paragraph direction,
232 * otherwise a direction will be chosen automatically from each paragraph's contents.
233 * @return {GetEmbeddingLevelsResult}
234 */
235 function getEmbeddingLevels (string, baseDirection) {
236 var MAX_DEPTH = 125;
237
238 // Start by mapping all characters to their unicode type, as a bitmask integer
239 var charTypes = new Uint32Array(string.length);
240 for (var i = 0; i < string.length; i++) {
241 charTypes[i] = getBidiCharType(string[i]);
242 }
243
244 var charTypeCounts = new Map(); //will be cleared at start of each paragraph
245 function changeCharType(i, type) {
246 var oldType = charTypes[i];
247 charTypes[i] = type;
248 charTypeCounts.set(oldType, charTypeCounts.get(oldType) - 1);
249 if (oldType & NEUTRAL_ISOLATE_TYPES) {
250 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) - 1);
251 }
252 charTypeCounts.set(type, (charTypeCounts.get(type) || 0) + 1);
253 if (type & NEUTRAL_ISOLATE_TYPES) {
254 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1);
255 }
256 }
257
258 var embedLevels = new Uint8Array(string.length);
259 var isolationPairs = new Map(); //init->pdi and pdi->init
260
261 // === 3.3.1 The Paragraph Level ===
262 // 3.3.1 P1: Split the text into paragraphs
263 var paragraphs = []; // [{start, end, level}, ...]
264 var paragraph = null;
265 for (var i$1 = 0; i$1 < string.length; i$1++) {
266 if (!paragraph) {
267 paragraphs.push(paragraph = {
268 start: i$1,
269 end: string.length - 1,
270 // 3.3.1 P2-P3: Determine the paragraph level
271 level: baseDirection === 'rtl' ? 1 : baseDirection === 'ltr' ? 0 : determineAutoEmbedLevel(i$1, false)
272 });
273 }
274 if (charTypes[i$1] & TYPE_B) {
275 paragraph.end = i$1;
276 paragraph = null;
277 }
278 }
279
280 var FORMATTING_TYPES = TYPE_RLE | TYPE_LRE | TYPE_RLO | TYPE_LRO | ISOLATE_INIT_TYPES | TYPE_PDI | TYPE_PDF | TYPE_B;
281 var nextEven = function (n) { return n + ((n & 1) ? 1 : 2); };
282 var nextOdd = function (n) { return n + ((n & 1) ? 2 : 1); };
283
284 // Everything from here on will operate per paragraph.
285 for (var paraIdx = 0; paraIdx < paragraphs.length; paraIdx++) {
286 paragraph = paragraphs[paraIdx];
287 var statusStack = [{
288 _level: paragraph.level,
289 _override: 0, //0=neutral, 1=L, 2=R
290 _isolate: 0 //bool
291 }];
292 var stackTop = (void 0);
293 var overflowIsolateCount = 0;
294 var overflowEmbeddingCount = 0;
295 var validIsolateCount = 0;
296 charTypeCounts.clear();
297
298 // === 3.3.2 Explicit Levels and Directions ===
299 for (var i$2 = paragraph.start; i$2 <= paragraph.end; i$2++) {
300 var charType = charTypes[i$2];
301 stackTop = statusStack[statusStack.length - 1];
302
303 // Set initial counts
304 charTypeCounts.set(charType, (charTypeCounts.get(charType) || 0) + 1);
305 if (charType & NEUTRAL_ISOLATE_TYPES) {
306 charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1);
307 }
308
309 // Explicit Embeddings: 3.3.2 X2 - X3
310 if (charType & FORMATTING_TYPES) { //prefilter all formatters
311 if (charType & (TYPE_RLE | TYPE_LRE)) {
312 embedLevels[i$2] = stackTop._level; // 5.2
313 var level = (charType === TYPE_RLE ? nextOdd : nextEven)(stackTop._level);
314 if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
315 statusStack.push({
316 _level: level,
317 _override: 0,
318 _isolate: 0
319 });
320 } else if (!overflowIsolateCount) {
321 overflowEmbeddingCount++;
322 }
323 }
324
325 // Explicit Overrides: 3.3.2 X4 - X5
326 else if (charType & (TYPE_RLO | TYPE_LRO)) {
327 embedLevels[i$2] = stackTop._level; // 5.2
328 var level$1 = (charType === TYPE_RLO ? nextOdd : nextEven)(stackTop._level);
329 if (level$1 <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
330 statusStack.push({
331 _level: level$1,
332 _override: (charType & TYPE_RLO) ? TYPE_R : TYPE_L,
333 _isolate: 0
334 });
335 } else if (!overflowIsolateCount) {
336 overflowEmbeddingCount++;
337 }
338 }
339
340 // Isolates: 3.3.2 X5a - X5c
341 else if (charType & ISOLATE_INIT_TYPES) {
342 // X5c - FSI becomes either RLI or LRI
343 if (charType & TYPE_FSI) {
344 charType = determineAutoEmbedLevel(i$2 + 1, true) === 1 ? TYPE_RLI : TYPE_LRI;
345 }
346
347 embedLevels[i$2] = stackTop._level;
348 if (stackTop._override) {
349 changeCharType(i$2, stackTop._override);
350 }
351 var level$2 = (charType === TYPE_RLI ? nextOdd : nextEven)(stackTop._level);
352 if (level$2 <= MAX_DEPTH && overflowIsolateCount === 0 && overflowEmbeddingCount === 0) {
353 validIsolateCount++;
354 statusStack.push({
355 _level: level$2,
356 _override: 0,
357 _isolate: 1,
358 _isolInitIndex: i$2
359 });
360 } else {
361 overflowIsolateCount++;
362 }
363 }
364
365 // Terminating Isolates: 3.3.2 X6a
366 else if (charType & TYPE_PDI) {
367 if (overflowIsolateCount > 0) {
368 overflowIsolateCount--;
369 } else if (validIsolateCount > 0) {
370 overflowEmbeddingCount = 0;
371 while (!statusStack[statusStack.length - 1]._isolate) {
372 statusStack.pop();
373 }
374 // Add to isolation pairs bidirectional mapping:
375 var isolInitIndex = statusStack[statusStack.length - 1]._isolInitIndex;
376 if (isolInitIndex != null) {
377 isolationPairs.set(isolInitIndex, i$2);
378 isolationPairs.set(i$2, isolInitIndex);
379 }
380 statusStack.pop();
381 validIsolateCount--;
382 }
383 stackTop = statusStack[statusStack.length - 1];
384 embedLevels[i$2] = stackTop._level;
385 if (stackTop._override) {
386 changeCharType(i$2, stackTop._override);
387 }
388 }
389
390
391 // Terminating Embeddings and Overrides: 3.3.2 X7
392 else if (charType & TYPE_PDF) {
393 if (overflowIsolateCount === 0) {
394 if (overflowEmbeddingCount > 0) {
395 overflowEmbeddingCount--;
396 } else if (!stackTop._isolate && statusStack.length > 1) {
397 statusStack.pop();
398 stackTop = statusStack[statusStack.length - 1];
399 }
400 }
401 embedLevels[i$2] = stackTop._level; // 5.2
402 }
403
404 // End of Paragraph: 3.3.2 X8
405 else if (charType & TYPE_B) {
406 embedLevels[i$2] = paragraph.level;
407 }
408 }
409
410 // Non-formatting characters: 3.3.2 X6
411 else {
412 embedLevels[i$2] = stackTop._level;
413 // NOTE: This exclusion of BN seems to go against what section 5.2 says, but is required for test passage
414 if (stackTop._override && charType !== TYPE_BN) {
415 changeCharType(i$2, stackTop._override);
416 }
417 }
418 }
419
420 // === 3.3.3 Preparations for Implicit Processing ===
421
422 // Remove all RLE, LRE, RLO, LRO, PDF, and BN characters: 3.3.3 X9
423 // Note: Due to section 5.2, we won't remove them, but we'll use the BN_LIKE_TYPES bitset to
424 // easily ignore them all from here on out.
425
426 // 3.3.3 X10
427 // Compute the set of isolating run sequences as specified by BD13
428 var levelRuns = [];
429 var currentRun = null;
430 for (var i$3 = paragraph.start; i$3 <= paragraph.end; i$3++) {
431 var charType$1 = charTypes[i$3];
432 if (!(charType$1 & BN_LIKE_TYPES)) {
433 var lvl = embedLevels[i$3];
434 var isIsolInit = charType$1 & ISOLATE_INIT_TYPES;
435 var isPDI = charType$1 === TYPE_PDI;
436 if (currentRun && lvl === currentRun._level) {
437 currentRun._end = i$3;
438 currentRun._endsWithIsolInit = isIsolInit;
439 } else {
440 levelRuns.push(currentRun = {
441 _start: i$3,
442 _end: i$3,
443 _level: lvl,
444 _startsWithPDI: isPDI,
445 _endsWithIsolInit: isIsolInit
446 });
447 }
448 }
449 }
450 var isolatingRunSeqs = []; // [{seqIndices: [], sosType: L|R, eosType: L|R}]
451 for (var runIdx = 0; runIdx < levelRuns.length; runIdx++) {
452 var run = levelRuns[runIdx];
453 if (!run._startsWithPDI || (run._startsWithPDI && !isolationPairs.has(run._start))) {
454 var seqRuns = [currentRun = run];
455 for (var pdiIndex = (void 0); currentRun && currentRun._endsWithIsolInit && (pdiIndex = isolationPairs.get(currentRun._end)) != null;) {
456 for (var i$4 = runIdx + 1; i$4 < levelRuns.length; i$4++) {
457 if (levelRuns[i$4]._start === pdiIndex) {
458 seqRuns.push(currentRun = levelRuns[i$4]);
459 break
460 }
461 }
462 }
463 // build flat list of indices across all runs:
464 var seqIndices = [];
465 for (var i$5 = 0; i$5 < seqRuns.length; i$5++) {
466 var run$1 = seqRuns[i$5];
467 for (var j = run$1._start; j <= run$1._end; j++) {
468 seqIndices.push(j);
469 }
470 }
471 // determine the sos/eos types:
472 var firstLevel = embedLevels[seqIndices[0]];
473 var prevLevel = paragraph.level;
474 for (var i$6 = seqIndices[0] - 1; i$6 >= 0; i$6--) {
475 if (!(charTypes[i$6] & BN_LIKE_TYPES)) { //5.2
476 prevLevel = embedLevels[i$6];
477 break
478 }
479 }
480 var lastIndex = seqIndices[seqIndices.length - 1];
481 var lastLevel = embedLevels[lastIndex];
482 var nextLevel = paragraph.level;
483 if (!(charTypes[lastIndex] & ISOLATE_INIT_TYPES)) {
484 for (var i$7 = lastIndex + 1; i$7 <= paragraph.end; i$7++) {
485 if (!(charTypes[i$7] & BN_LIKE_TYPES)) { //5.2
486 nextLevel = embedLevels[i$7];
487 break
488 }
489 }
490 }
491 isolatingRunSeqs.push({
492 _seqIndices: seqIndices,
493 _sosType: Math.max(prevLevel, firstLevel) % 2 ? TYPE_R : TYPE_L,
494 _eosType: Math.max(nextLevel, lastLevel) % 2 ? TYPE_R : TYPE_L
495 });
496 }
497 }
498
499 // The next steps are done per isolating run sequence
500 for (var seqIdx = 0; seqIdx < isolatingRunSeqs.length; seqIdx++) {
501 var ref = isolatingRunSeqs[seqIdx];
502 var seqIndices$1 = ref._seqIndices;
503 var sosType = ref._sosType;
504 var eosType = ref._eosType;
505 /**
506 * All the level runs in an isolating run sequence have the same embedding level.
507 *
508 * DO NOT change any `embedLevels[i]` within the current scope.
509 */
510 var embedDirection = ((embedLevels[seqIndices$1[0]]) & 1) ? TYPE_R : TYPE_L;
511
512 // === 3.3.4 Resolving Weak Types ===
513
514 // W1 + 5.2. Search backward from each NSM to the first character in the isolating run sequence whose
515 // bidirectional type is not BN, and set the NSM to ON if it is an isolate initiator or PDI, and to its
516 // type otherwise. If the NSM is the first non-BN character, change the NSM to the type of sos.
517 if (charTypeCounts.get(TYPE_NSM)) {
518 for (var si = 0; si < seqIndices$1.length; si++) {
519 var i$8 = seqIndices$1[si];
520 if (charTypes[i$8] & TYPE_NSM) {
521 var prevType = sosType;
522 for (var sj = si - 1; sj >= 0; sj--) {
523 if (!(charTypes[seqIndices$1[sj]] & BN_LIKE_TYPES)) { //5.2 scan back to first non-BN
524 prevType = charTypes[seqIndices$1[sj]];
525 break
526 }
527 }
528 changeCharType(i$8, (prevType & (ISOLATE_INIT_TYPES | TYPE_PDI)) ? TYPE_ON : prevType);
529 }
530 }
531 }
532
533 // W2. Search backward from each instance of a European number until the first strong type (R, L, AL, or sos)
534 // is found. If an AL is found, change the type of the European number to Arabic number.
535 if (charTypeCounts.get(TYPE_EN)) {
536 for (var si$1 = 0; si$1 < seqIndices$1.length; si$1++) {
537 var i$9 = seqIndices$1[si$1];
538 if (charTypes[i$9] & TYPE_EN) {
539 for (var sj$1 = si$1 - 1; sj$1 >= -1; sj$1--) {
540 var prevCharType = sj$1 === -1 ? sosType : charTypes[seqIndices$1[sj$1]];
541 if (prevCharType & STRONG_TYPES) {
542 if (prevCharType === TYPE_AL) {
543 changeCharType(i$9, TYPE_AN);
544 }
545 break
546 }
547 }
548 }
549 }
550 }
551
552 // W3. Change all ALs to R
553 if (charTypeCounts.get(TYPE_AL)) {
554 for (var si$2 = 0; si$2 < seqIndices$1.length; si$2++) {
555 var i$10 = seqIndices$1[si$2];
556 if (charTypes[i$10] & TYPE_AL) {
557 changeCharType(i$10, TYPE_R);
558 }
559 }
560 }
561
562 // W4. A single European separator between two European numbers changes to a European number. A single common
563 // separator between two numbers of the same type changes to that type.
564 if (charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
565 for (var si$3 = 1; si$3 < seqIndices$1.length - 1; si$3++) {
566 var i$11 = seqIndices$1[si$3];
567 if (charTypes[i$11] & (TYPE_ES | TYPE_CS)) {
568 var prevType$1 = 0, nextType = 0;
569 for (var sj$2 = si$3 - 1; sj$2 >= 0; sj$2--) {
570 prevType$1 = charTypes[seqIndices$1[sj$2]];
571 if (!(prevType$1 & BN_LIKE_TYPES)) { //5.2
572 break
573 }
574 }
575 for (var sj$3 = si$3 + 1; sj$3 < seqIndices$1.length; sj$3++) {
576 nextType = charTypes[seqIndices$1[sj$3]];
577 if (!(nextType & BN_LIKE_TYPES)) { //5.2
578 break
579 }
580 }
581 if (prevType$1 === nextType && (charTypes[i$11] === TYPE_ES ? prevType$1 === TYPE_EN : (prevType$1 & (TYPE_EN | TYPE_AN)))) {
582 changeCharType(i$11, prevType$1);
583 }
584 }
585 }
586 }
587
588 // W5. A sequence of European terminators adjacent to European numbers changes to all European numbers.
589 if (charTypeCounts.get(TYPE_EN)) {
590 for (var si$4 = 0; si$4 < seqIndices$1.length; si$4++) {
591 var i$12 = seqIndices$1[si$4];
592 if (charTypes[i$12] & TYPE_EN) {
593 for (var sj$4 = si$4 - 1; sj$4 >= 0 && (charTypes[seqIndices$1[sj$4]] & (TYPE_ET | BN_LIKE_TYPES)); sj$4--) {
594 changeCharType(seqIndices$1[sj$4], TYPE_EN);
595 }
596 for (si$4++; si$4 < seqIndices$1.length && (charTypes[seqIndices$1[si$4]] & (TYPE_ET | BN_LIKE_TYPES | TYPE_EN)); si$4++) {
597 if (charTypes[seqIndices$1[si$4]] !== TYPE_EN) {
598 changeCharType(seqIndices$1[si$4], TYPE_EN);
599 }
600 }
601 }
602 }
603 }
604
605 // W6. Otherwise, separators and terminators change to Other Neutral.
606 if (charTypeCounts.get(TYPE_ET) || charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
607 for (var si$5 = 0; si$5 < seqIndices$1.length; si$5++) {
608 var i$13 = seqIndices$1[si$5];
609 if (charTypes[i$13] & (TYPE_ET | TYPE_ES | TYPE_CS)) {
610 changeCharType(i$13, TYPE_ON);
611 // 5.2 transform adjacent BNs too:
612 for (var sj$5 = si$5 - 1; sj$5 >= 0 && (charTypes[seqIndices$1[sj$5]] & BN_LIKE_TYPES); sj$5--) {
613 changeCharType(seqIndices$1[sj$5], TYPE_ON);
614 }
615 for (var sj$6 = si$5 + 1; sj$6 < seqIndices$1.length && (charTypes[seqIndices$1[sj$6]] & BN_LIKE_TYPES); sj$6++) {
616 changeCharType(seqIndices$1[sj$6], TYPE_ON);
617 }
618 }
619 }
620 }
621
622 // W7. Search backward from each instance of a European number until the first strong type (R, L, or sos)
623 // is found. If an L is found, then change the type of the European number to L.
624 // NOTE: implemented in single forward pass for efficiency
625 if (charTypeCounts.get(TYPE_EN)) {
626 for (var si$6 = 0, prevStrongType = sosType; si$6 < seqIndices$1.length; si$6++) {
627 var i$14 = seqIndices$1[si$6];
628 var type = charTypes[i$14];
629 if (type & TYPE_EN) {
630 if (prevStrongType === TYPE_L) {
631 changeCharType(i$14, TYPE_L);
632 }
633 } else if (type & STRONG_TYPES) {
634 prevStrongType = type;
635 }
636 }
637 }
638
639 // === 3.3.5 Resolving Neutral and Isolate Formatting Types ===
640
641 if (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES)) {
642 // N0. Process bracket pairs in an isolating run sequence sequentially in the logical order of the text
643 // positions of the opening paired brackets using the logic given below. Within this scope, bidirectional
644 // types EN and AN are treated as R.
645 var R_TYPES_FOR_N_STEPS = (TYPE_R | TYPE_EN | TYPE_AN);
646 var STRONG_TYPES_FOR_N_STEPS = R_TYPES_FOR_N_STEPS | TYPE_L;
647
648 // * Identify the bracket pairs in the current isolating run sequence according to BD16.
649 var bracketPairs = [];
650 {
651 var openerStack = [];
652 for (var si$7 = 0; si$7 < seqIndices$1.length; si$7++) {
653 // NOTE: for any potential bracket character we also test that it still carries a NI
654 // type, as that may have been changed earlier. This doesn't seem to be explicitly
655 // called out in the spec, but is required for passage of certain tests.
656 if (charTypes[seqIndices$1[si$7]] & NEUTRAL_ISOLATE_TYPES) {
657 var char = string[seqIndices$1[si$7]];
658 var oppositeBracket = (void 0);
659 // Opening bracket
660 if (openingToClosingBracket(char) !== null) {
661 if (openerStack.length < 63) {
662 openerStack.push({ char: char, seqIndex: si$7 });
663 } else {
664 break
665 }
666 }
667 // Closing bracket
668 else if ((oppositeBracket = closingToOpeningBracket(char)) !== null) {
669 for (var stackIdx = openerStack.length - 1; stackIdx >= 0; stackIdx--) {
670 var stackChar = openerStack[stackIdx].char;
671 if (stackChar === oppositeBracket ||
672 stackChar === closingToOpeningBracket(getCanonicalBracket(char)) ||
673 openingToClosingBracket(getCanonicalBracket(stackChar)) === char
674 ) {
675 bracketPairs.push([openerStack[stackIdx].seqIndex, si$7]);
676 openerStack.length = stackIdx; //pop the matching bracket and all following
677 break
678 }
679 }
680 }
681 }
682 }
683 bracketPairs.sort(function (a, b) { return a[0] - b[0]; });
684 }
685 // * For each bracket-pair element in the list of pairs of text positions
686 for (var pairIdx = 0; pairIdx < bracketPairs.length; pairIdx++) {
687 var ref$1 = bracketPairs[pairIdx];
688 var openSeqIdx = ref$1[0];
689 var closeSeqIdx = ref$1[1];
690 // a. Inspect the bidirectional types of the characters enclosed within the bracket pair.
691 // b. If any strong type (either L or R) matching the embedding direction is found, set the type for both
692 // brackets in the pair to match the embedding direction.
693 var foundStrongType = false;
694 var useStrongType = 0;
695 for (var si$8 = openSeqIdx + 1; si$8 < closeSeqIdx; si$8++) {
696 var i$15 = seqIndices$1[si$8];
697 if (charTypes[i$15] & STRONG_TYPES_FOR_N_STEPS) {
698 foundStrongType = true;
699 var lr = (charTypes[i$15] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L;
700 if (lr === embedDirection) {
701 useStrongType = lr;
702 break
703 }
704 }
705 }
706 // c. Otherwise, if there is a strong type it must be opposite the embedding direction. Therefore, test
707 // for an established context with a preceding strong type by checking backwards before the opening paired
708 // bracket until the first strong type (L, R, or sos) is found.
709 // 1. If the preceding strong type is also opposite the embedding direction, context is established, so
710 // set the type for both brackets in the pair to that direction.
711 // 2. Otherwise set the type for both brackets in the pair to the embedding direction.
712 if (foundStrongType && !useStrongType) {
713 useStrongType = sosType;
714 for (var si$9 = openSeqIdx - 1; si$9 >= 0; si$9--) {
715 var i$16 = seqIndices$1[si$9];
716 if (charTypes[i$16] & STRONG_TYPES_FOR_N_STEPS) {
717 var lr$1 = (charTypes[i$16] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L;
718 if (lr$1 !== embedDirection) {
719 useStrongType = lr$1;
720 } else {
721 useStrongType = embedDirection;
722 }
723 break
724 }
725 }
726 }
727 if (useStrongType) {
728 charTypes[seqIndices$1[openSeqIdx]] = charTypes[seqIndices$1[closeSeqIdx]] = useStrongType;
729 // * Any number of characters that had original bidirectional character type NSM prior to the application
730 // of W1 that immediately follow a paired bracket which changed to L or R under N0 should change to match
731 // the type of their preceding bracket.
732 if (useStrongType !== embedDirection) {
733 for (var si$10 = openSeqIdx + 1; si$10 < seqIndices$1.length; si$10++) {
734 if (!(charTypes[seqIndices$1[si$10]] & BN_LIKE_TYPES)) {
735 if (getBidiCharType(string[seqIndices$1[si$10]]) & TYPE_NSM) {
736 charTypes[seqIndices$1[si$10]] = useStrongType;
737 }
738 break
739 }
740 }
741 }
742 if (useStrongType !== embedDirection) {
743 for (var si$11 = closeSeqIdx + 1; si$11 < seqIndices$1.length; si$11++) {
744 if (!(charTypes[seqIndices$1[si$11]] & BN_LIKE_TYPES)) {
745 if (getBidiCharType(string[seqIndices$1[si$11]]) & TYPE_NSM) {
746 charTypes[seqIndices$1[si$11]] = useStrongType;
747 }
748 break
749 }
750 }
751 }
752 }
753 }
754
755 // N1. A sequence of NIs takes the direction of the surrounding strong text if the text on both sides has the
756 // same direction.
757 // N2. Any remaining NIs take the embedding direction.
758 for (var si$12 = 0; si$12 < seqIndices$1.length; si$12++) {
759 if (charTypes[seqIndices$1[si$12]] & NEUTRAL_ISOLATE_TYPES) {
760 var niRunStart = si$12, niRunEnd = si$12;
761 var prevType$2 = sosType; //si === 0 ? sosType : (charTypes[seqIndices[si - 1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
762 for (var si2 = si$12 - 1; si2 >= 0; si2--) {
763 if (charTypes[seqIndices$1[si2]] & BN_LIKE_TYPES) {
764 niRunStart = si2; //5.2 treat BNs adjacent to NIs as NIs
765 } else {
766 prevType$2 = (charTypes[seqIndices$1[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L;
767 break
768 }
769 }
770 var nextType$1 = eosType;
771 for (var si2$1 = si$12 + 1; si2$1 < seqIndices$1.length; si2$1++) {
772 if (charTypes[seqIndices$1[si2$1]] & (NEUTRAL_ISOLATE_TYPES | BN_LIKE_TYPES)) {
773 niRunEnd = si2$1;
774 } else {
775 nextType$1 = (charTypes[seqIndices$1[si2$1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L;
776 break
777 }
778 }
779 for (var sj$7 = niRunStart; sj$7 <= niRunEnd; sj$7++) {
780 charTypes[seqIndices$1[sj$7]] = prevType$2 === nextType$1 ? prevType$2 : embedDirection;
781 }
782 si$12 = niRunEnd;
783 }
784 }
785 }
786 }
787
788 // === 3.3.6 Resolving Implicit Levels ===
789
790 for (var i$17 = paragraph.start; i$17 <= paragraph.end; i$17++) {
791 var level$3 = embedLevels[i$17];
792 var type$1 = charTypes[i$17];
793 // I2. For all characters with an odd (right-to-left) embedding level, those of type L, EN or AN go up one level.
794 if (level$3 & 1) {
795 if (type$1 & (TYPE_L | TYPE_EN | TYPE_AN)) {
796 embedLevels[i$17]++;
797 }
798 }
799 // I1. For all characters with an even (left-to-right) embedding level, those of type R go up one level
800 // and those of type AN or EN go up two levels.
801 else {
802 if (type$1 & TYPE_R) {
803 embedLevels[i$17]++;
804 } else if (type$1 & (TYPE_AN | TYPE_EN)) {
805 embedLevels[i$17] += 2;
806 }
807 }
808
809 // 5.2: Resolve any LRE, RLE, LRO, RLO, PDF, or BN to the level of the preceding character if there is one,
810 // and otherwise to the base level.
811 if (type$1 & BN_LIKE_TYPES) {
812 embedLevels[i$17] = i$17 === 0 ? paragraph.level : embedLevels[i$17 - 1];
813 }
814
815 // 3.4 L1.1-4: Reset the embedding level of segment/paragraph separators, and any sequence of whitespace or
816 // isolate formatting characters preceding them or the end of the paragraph, to the paragraph level.
817 // NOTE: this will also need to be applied to each individual line ending after line wrapping occurs.
818 if (i$17 === paragraph.end || getBidiCharType(string[i$17]) & (TYPE_S | TYPE_B)) {
819 for (var j$1 = i$17; j$1 >= 0 && (getBidiCharType(string[j$1]) & TRAILING_TYPES); j$1--) {
820 embedLevels[j$1] = paragraph.level;
821 }
822 }
823 }
824 }
825
826 // DONE! The resolved levels can then be used, after line wrapping, to flip runs of characters
827 // according to section 3.4 Reordering Resolved Levels
828 return {
829 levels: embedLevels,
830 paragraphs: paragraphs
831 }
832
833 function determineAutoEmbedLevel (start, isFSI) {
834 // 3.3.1 P2 - P3
835 for (var i = start; i < string.length; i++) {
836 var charType = charTypes[i];
837 if (charType & (TYPE_R | TYPE_AL)) {
838 return 1
839 }
840 if ((charType & (TYPE_B | TYPE_L)) || (isFSI && charType === TYPE_PDI)) {
841 return 0
842 }
843 if (charType & ISOLATE_INIT_TYPES) {
844 var pdi = indexOfMatchingPDI(i);
845 i = pdi === -1 ? string.length : pdi;
846 }
847 }
848 return 0
849 }
850
851 function indexOfMatchingPDI (isolateStart) {
852 // 3.1.2 BD9
853 var isolationLevel = 1;
854 for (var i = isolateStart + 1; i < string.length; i++) {
855 var charType = charTypes[i];
856 if (charType & TYPE_B) {
857 break
858 }
859 if (charType & TYPE_PDI) {
860 if (--isolationLevel === 0) {
861 return i
862 }
863 } else if (charType & ISOLATE_INIT_TYPES) {
864 isolationLevel++;
865 }
866 }
867 return -1
868 }
869 }
870
871 // Bidi mirrored chars data, auto generated
872 var data = "14>1,j>2,t>2,u>2,1a>g,2v3>1,1>1,1ge>1,1wd>1,b>1,1j>1,f>1,ai>3,-2>3,+1,8>1k0,-1jq>1y7,-1y6>1hf,-1he>1h6,-1h5>1ha,-1h8>1qi,-1pu>1,6>3u,-3s>7,6>1,1>1,f>1,1>1,+2,3>1,1>1,+13,4>1,1>1,6>1eo,-1ee>1,3>1mg,-1me>1mk,-1mj>1mi,-1mg>1mi,-1md>1,1>1,+2,1>10k,-103>1,1>1,4>1,5>1,1>1,+10,3>1,1>8,-7>8,+1,-6>7,+1,a>1,1>1,u>1,u6>1,1>1,+5,26>1,1>1,2>1,2>2,8>1,7>1,4>1,1>1,+5,b8>1,1>1,+3,1>3,-2>1,2>1,1>1,+2,c>1,3>1,1>1,+2,h>1,3>1,a>1,1>1,2>1,3>1,1>1,d>1,f>1,3>1,1a>1,1>1,6>1,7>1,13>1,k>1,1>1,+19,4>1,1>1,+2,2>1,1>1,+18,m>1,a>1,1>1,lk>1,1>1,4>1,2>1,f>1,3>1,1>1,+3,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,6>1,4j>1,j>2,t>2,u>2,2>1,+1";
873
874 var mirrorMap;
875
876 function parse () {
877 if (!mirrorMap) {
878 //const start = performance.now()
879 var ref = parseCharacterMap(data, true);
880 var map = ref.map;
881 var reverseMap = ref.reverseMap;
882 // Combine both maps into one
883 reverseMap.forEach(function (value, key) {
884 map.set(key, value);
885 });
886 mirrorMap = map;
887 //console.log(`mirrored chars parsed in ${performance.now() - start}ms`)
888 }
889 }
890
891 /**
892 * Get the mirrored character for a given character, if one exists.
893 * @param {string} char
894 * @return {string|null}
895 */
896 function getMirroredCharacter (char) {
897 parse();
898 return mirrorMap.get(char) || null
899 }
900
901 /**
902 * Given a string and its resolved embedding levels, build a map of indices to replacement chars
903 * for any characters in right-to-left segments that have defined mirrored characters.
904 * @param {string} string
905 * @param {Uint8Array} embeddingLevels
906 * @param {number?} [start]
907 * @param {number?} [end]
908 * @return {Map<number, string>}
909 */
910 function getMirroredCharactersMap(string, embeddingLevels, start, end) {
911 var strLen = string.length;
912 start = Math.max(0, start == null ? 0 : +start);
913 end = Math.min(strLen - 1, end == null ? strLen - 1 : +end);
914
915 var map = new Map();
916 for (var i = start; i <= end; i++) {
917 if (embeddingLevels[i] & 1) { //only odd (rtl) levels
918 var mirror = getMirroredCharacter(string[i]);
919 if (mirror !== null) {
920 map.set(i, mirror);
921 }
922 }
923 }
924 return map
925 }
926
927 /**
928 * Given a start and end denoting a single line within a string, and a set of precalculated
929 * bidi embedding levels, produce a list of segments whose ordering should be flipped, in sequence.
930 * @param {string} string - the full input string
931 * @param {GetEmbeddingLevelsResult} embeddingLevelsResult - the result object from getEmbeddingLevels
932 * @param {number} [start] - first character in a subset of the full string
933 * @param {number} [end] - last character in a subset of the full string
934 * @return {number[][]} - the list of start/end segments that should be flipped, in order.
935 */
936 function getReorderSegments(string, embeddingLevelsResult, start, end) {
937 var strLen = string.length;
938 start = Math.max(0, start == null ? 0 : +start);
939 end = Math.min(strLen - 1, end == null ? strLen - 1 : +end);
940
941 var segments = [];
942 embeddingLevelsResult.paragraphs.forEach(function (paragraph) {
943 var lineStart = Math.max(start, paragraph.start);
944 var lineEnd = Math.min(end, paragraph.end);
945 if (lineStart < lineEnd) {
946 // Local slice for mutation
947 var lineLevels = embeddingLevelsResult.levels.slice(lineStart, lineEnd + 1);
948
949 // 3.4 L1.4: Reset any sequence of whitespace characters and/or isolate formatting characters at the
950 // end of the line to the paragraph level.
951 for (var i = lineEnd; i >= lineStart && (getBidiCharType(string[i]) & TRAILING_TYPES); i--) {
952 lineLevels[i] = paragraph.level;
953 }
954
955 // L2. From the highest level found in the text to the lowest odd level on each line, including intermediate levels
956 // not actually present in the text, reverse any contiguous sequence of characters that are at that level or higher.
957 var maxLevel = paragraph.level;
958 var minOddLevel = Infinity;
959 for (var i$1 = 0; i$1 < lineLevels.length; i$1++) {
960 var level = lineLevels[i$1];
961 if (level > maxLevel) { maxLevel = level; }
962 if (level < minOddLevel) { minOddLevel = level | 1; }
963 }
964 for (var lvl = maxLevel; lvl >= minOddLevel; lvl--) {
965 for (var i$2 = 0; i$2 < lineLevels.length; i$2++) {
966 if (lineLevels[i$2] >= lvl) {
967 var segStart = i$2;
968 while (i$2 + 1 < lineLevels.length && lineLevels[i$2 + 1] >= lvl) {
969 i$2++;
970 }
971 if (i$2 > segStart) {
972 segments.push([segStart + lineStart, i$2 + lineStart]);
973 }
974 }
975 }
976 }
977 }
978 });
979 return segments
980 }
981
982 /**
983 * @param {string} string
984 * @param {GetEmbeddingLevelsResult} embedLevelsResult
985 * @param {number} [start]
986 * @param {number} [end]
987 * @return {string} the new string with bidi segments reordered
988 */
989 function getReorderedString(string, embedLevelsResult, start, end) {
990 var indices = getReorderedIndices(string, embedLevelsResult, start, end);
991 var chars = [].concat( string );
992 indices.forEach(function (charIndex, i) {
993 chars[i] = (
994 (embedLevelsResult.levels[charIndex] & 1) ? getMirroredCharacter(string[charIndex]) : null
995 ) || string[charIndex];
996 });
997 return chars.join('')
998 }
999
1000 /**
1001 * @param {string} string
1002 * @param {GetEmbeddingLevelsResult} embedLevelsResult
1003 * @param {number} [start]
1004 * @param {number} [end]
1005 * @return {number[]} an array with character indices in their new bidi order
1006 */
1007 function getReorderedIndices(string, embedLevelsResult, start, end) {
1008 var segments = getReorderSegments(string, embedLevelsResult, start, end);
1009 // Fill an array with indices
1010 var indices = [];
1011 for (var i = 0; i < string.length; i++) {
1012 indices[i] = i;
1013 }
1014 // Reverse each segment in order
1015 segments.forEach(function (ref) {
1016 var start = ref[0];
1017 var end = ref[1];
1018
1019 var slice = indices.slice(start, end + 1);
1020 for (var i = slice.length; i--;) {
1021 indices[end - i] = slice[i];
1022 }
1023 });
1024 return indices
1025 }
1026
1027 exports.closingToOpeningBracket = closingToOpeningBracket;
1028 exports.getBidiCharType = getBidiCharType;
1029 exports.getBidiCharTypeName = getBidiCharTypeName;
1030 exports.getCanonicalBracket = getCanonicalBracket;
1031 exports.getEmbeddingLevels = getEmbeddingLevels;
1032 exports.getMirroredCharacter = getMirroredCharacter;
1033 exports.getMirroredCharactersMap = getMirroredCharactersMap;
1034 exports.getReorderSegments = getReorderSegments;
1035 exports.getReorderedIndices = getReorderedIndices;
1036 exports.getReorderedString = getReorderedString;
1037 exports.openingToClosingBracket = openingToClosingBracket;
1038
1039 Object.defineProperty(exports, '__esModule', { value: true });
1040
1041 return exports;
1042
1043})({});
1044return bidi}
1045
1046export { bidiFactory as default };
1047