SLOPSHOPPER

rtl

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

newpanebandspinnerrowscommand
v0.0.1MITupdated 2026-10-05ofekbetzalel/claude-code-cli-rtl/mod/rtl
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rtl
│ ┃ Draft ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Type in the prompt box; it ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /rtl-draft │ ⎿ rtl: Draft pane open. /rtl-draft again closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Draft
│ Type in the prompt box; it shows here.
README

Claude Code CLI RTL

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

Tests License: MIT Claude Code 2.1.287+

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>

Install

You need Claude Code 2.1.287 or newer (claude --version).

For humans

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.

For AI agents

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:

  1. Run 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.
  2. Add the marketplace (safe to run even if it is already added): ``bash claude plugin marketplace add ofekbetzalel/claude-code-cli-rtl claude plugin marketplace update claude-code-cli-rtl ``
  3. Run claude plugin list and look for rtl@claude-code-cli-rtl:
  4. If it is not listed, run claude plugin install rtl@claude-code-cli-rtl.
  5. If it is listed, run claude plugin update rtl@claude-code-cli-rtl.
  6. If it is listed with Status: ✘ disabled, also run claude plugin enable rtl@claude-code-cli-rtl.
  7. Run claude plugin list again and confirm that rtl@claude-code-cli-rtl shows Status: ✔ enabled. Do not report success before this.
  8. Ask the user whether they read Arabic or Persian in a terminal other than Windows Terminal. Only if they do, set this option. It does not change their other settings: ``bash echo '{"arabic":"forms"}' | claude plugin configure rtl@claude-code-cli-rtl --values-stdin ``
  9. Tell the user to restart Claude Code. The session that is already running will not load the mod.

Do not change other settings, clone the repository or install anything else.

Update

claude plugin marketplace update claude-code-cli-rtl
claude plugin update rtl@claude-code-cli-rtl

Uninstall

claude plugin uninstall rtl@claude-code-cli-rtl

Restart Claude Code after updating or removing the mod.

What it fixes

  • Sentences read right-to-left and are aligned to the right, even when they start with an English word or a file path.
  • Mixed text: English words, numbers, versions, paths, inline code and links stay left-to-right inside the sentence.
  • Markdown: headings, bold and italic text, quotes, bullet and numbered lists (markers on the right), and tables (columns run from the right).
  • Code blocks: Hebrew, Arabic and Persian comments and strings read right-to-left, while the code around them stays left-to-right.
  • Lists: an English item in a Hebrew list stays on the list's side.
  • Emoji and brackets appear on the correct side of the words around them.
  • Arabic and Persian: letters join into words, including lam-alef (لا), and Persian words with a zero-width non-joiner (می‌خواهم) keep their break. Windows Terminal joins the letters itself; the other terminals need arabic set to forms.
  • Your own messages in the conversation, and the output of commands such as /recap.
  • What you type: the band above the prompt and the /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.

Seeing what you type

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.

Terminal support

Tested on Windows 11 with Ubuntu in WSL and Claude Code 2.1.289. Screenshots from each terminal.

Windows TerminalWezTermKonsole&nbsp;¹mintty&nbsp;²Browser (xterm.js)
Hebrew reads right-to-left✅✅✅❌✅
Lines aligned right✅✅✅✅✅
English and numbers in place✅✅✅✅✅
Arabic and Persian letters joined✅✅&nbsp;³✅&nbsp;³✅&nbsp;³✅&nbsp;³
  1. Turn off Bi-Directional text rendering in the Konsole profile.
  2. wsltty, with mintty's bidi turned off (-o Bidi=0). Hebrew letters still come out reversed inside each word.
  3. With 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.

Settings

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).

SettingValuesWhat it does
Character order<br>ordervisual (default), logical, offvisual: 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>directionrtl-share (default), first-strongrtl-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 emptyThe 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>previewon (default), draft, offThe 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>recapoff (default), onon: 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>arabicletters (default), formsletters: 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.

Limitations

  • The input box is drawn by Claude Code, and mods cannot redraw it. In most terminals, right-to-left text in the box appears reversed. In Windows Terminal, Claude Code reorders the text itself, but aligns it to the left. The cursor may show in the wrong place, and a wrapped line with English words or numbers can be out of order. To see your draft right-to-left, use /rtl-draft or the band above the prompt (Seeing what you type). Once you send it, your message is drawn right-to-left.
  • The preview band needs room. Claude Code gives the prompt and the band at most half the terminal, so a long draft in a short terminal leaves no room for the band.
  • Replies are redrawn when they finish. While a reply streams it is shown left-to-right.
  • Tool output and permission dialogs are drawn by Claude Code as usual.
  • Claude Code's automatic recap (※ 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.
  • Code lines that hold right-to-left text keep the colors of their comments and strings, but the rest of the line (keywords, names) loses its syntax colors.
  • Copying text from the terminal gives right-to-left text in its on-screen order, which is reversed.
  • Very long replies and Mermaid diagrams may be drawn by Claude Code as usual, with a dim note under the reply that says why.
  • Colors: the mod needs a terminal with 24-bit color. In browser-based terminals, avoid the dark-ansi and light-ansi themes, which can show right-to-left words reversed.
  • Arabic and Persian in Windows Terminal: keep 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.

How it works

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.

Contributing

Bug reports with a screenshot, the text, and your terminal's name help the most. See CONTRIBUTING.md.

License

MIT. Includes bidi-js (MIT).

This is an independent project, not affiliated with Anthropic.

Source 15 files
hooks/register.tsx 805 lines
1import 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}
805
src/bidi.ts 297 lines
1// 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}
297
src/code.ts 686 lines
1// 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}
686
src/isolate.ts 141 lines
1// 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}
141
src/layout.ts 230 lines
1// 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}
230
src/markdown.ts 511 lines
1// 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}
511
src/render.ts 514 lines
1// 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)
514
src/options.ts 20 lines
1// 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}
20
src/preview.ts 224 lines
1// 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 }
224
src/recap.ts 198 lines
1// 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}
198
src/width.ts 105 lines
1// 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}
105
vendor/bidi.mjs 1047 lines
1// 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