SLOPSHOPPER

RTL Terminal

Right-to-left layout for Arabic, Hebrew, Persian, Urdu and every RTL language in the Claude Code terminal

newbandrowscommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rtl-terminal
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rtl ⎿ rtl-terminal: RTL layout is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="assets/ClaudeCode Launchpad CLI.jpeg" width="700" alt="ClaudeCode Launchpad CLI">

<img src="assets/claudecode-launchpad_v2.6.9.gif" width="700" alt="ClaudeCode Launchpad CLI in action - installer wizard, terminal launch, status bar">

<em>📹 Demo: ClaudeCode Launchpad CLI, one-click install, folder picker, and launch. Full video (1:56): <a href="https://github.com/noambrand/launchpad-cli/releases/download/v2.6.9/claudecode-launchpad_v2.6.9.mp4">download MP4 (2.2 MB)</a>.</em>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a> <a href="https://github.com/noambrand/launchpad-cli/releases/latest"><img src="https://img.shields.io/github/v/release/noambrand/launchpad-cli?label=version&color=brightgreen&cb=v2.8.1-lc" alt="Latest release"></a> <img src="https://img.shields.io/badge/platform-Windows%2010%2F11%20%7C%20macOS%2012%2B-lightgrey" alt="Platform"> <img src="https://img.shields.io/badge/languages-24%2B-orange" alt="24+ Languages"> <a href="https://github.com/noambrand/launchpad-cli/stargazers"><img src="https://img.shields.io/github/stars/noambrand/launchpad-cli?style=flat&color=yellow&cb=lc1" alt="GitHub Stars"></a> <img src="https://img.shields.io/github/last-commit/noambrand/launchpad-cli?label=last%20commit&color=brightgreen&cb=lc1" alt="Last Commit"> <img src="https://img.shields.io/badge/downloads-1491%2B-blue" alt="Total Downloads">

<h3 align="center">Point it at a folder, describe the job in your own language, and Anthropic's AI agent does the work on your own files.</h3>

<b>Built for people who do not write code.</b> Claude Code is a genuinely capable AI agent, but it lives in a terminal built for programmers, and that is where most people stop. This puts a normal window in front of it: pick your folder, type a sentence, watch it work. Nothing to configure, no commands to memorise, no administrator rights.

<table align="center"> <tr><th align="left">Instead of doing this by hand</th><th align="left">You type this</th></tr> <tr><td>Combining 40 monthly spreadsheets into one summary</td><td><em>"Take all the files in this folder and build one summary sheet, one row per month."</em></td></tr> <tr><td>Renaming and sorting a folder of hundreds of documents</td><td><em>"Rename these by date and client, and sort them into folders per year."</em></td></tr> <tr><td>Hunting for what changed between two versions</td><td><em>"Compare last month's list to this one and show me only what changed."</em></td></tr> <tr><td>Reading a file someone else built and left behind</td><td><em>"Explain what this workbook calculates and flag anything broken."</em></td></tr> </table>

<a href="#-quick-start">Quick Start</a> &bull; <a href="#-why-launchpad-cli">Why Launchpad CLI?</a> &bull; <a href="#-status-bar">Status Bar</a> &bull; <a href="#-architecture">Architecture</a> &bull; <a href="#-configuration">Configuration</a> &bull; <a href="docs/CHANGELOG.md">Changelog</a> &bull; <a href="TROUBLESHOOTING.md">Troubleshooting</a>


Why Launchpad CLI?

Manual SetupLaunchpad CLI
Get Claude Code runningFind Node.js, Git, and Claude installers, run them in order, fix PATHOne installer, one click
Live status bar (model, context %, usage)Write your own statusline script + configure settings.jsonPre-installed
Desktop shortcut + right-click "Open with..."Manual .lnk files + registry editsIncluded
Pick a folder before launchingcd into every projectGUI picker dialog (browse tree or paste a path)
Default Claude flags + startup slash commandsType them every sessionSet once in the picker, reused every launch
Named profiles per project (folder + model + flags + env vars + startup slash-commands)Track combos in your head, retype every session🆕 v2.6.0 — chip row at top of picker, click to switch; ANTHROPIC_API_KEY etc. masked in preview by default
Time to first prompt20+ minutes~1 minute

<a href="https://github.com/noambrand/launchpad-cli/releases/latest/download/ClaudeCode_Launchpad_CLI_Setup.exe"><img src="https://img.shields.io/badge/Download%20for%20Windows-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZmZmZiI%2BPHBhdGggZD0iTTAgMy40NDkgOS43NSAyLjF2OS40NTFIMG0xMC45NDktOS42MDJMMjQgMHYxMS40SDEwLjk0OU0wIDEyLjZoOS43NXY5LjQ1MUwwIDIwLjY5OU0xMC45NDkgMTIuNkgyNFYyNGwtMTIuOS0xLjgwMSIvPjwvc3ZnPg%3D%3D&logoColor=white" alt="Download for Windows" height="42"></a> &nbsp; <a href="https://github.com/noambrand/launchpad-cli/releases/latest/download/ClaudeCode_Launchpad_CLI_Setup_mac.pkg"><img src="https://img.shields.io/badge/Download%20for%20macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" height="42"></a>

Here's the picker you'll get

<img src="assets/picker.png" alt="ClaudeCode Launchpad CLI folder picker (v2.6.16) — folder selection up top, an Advanced options toggle that hides model / flags / startup slash-commands / env-vars by default, and a yellow 'Update available' banner that checks GitHub Releases on launch and offers a one-click Download button" width="780">

The desktop shortcut opens this picker: pick a profile from the chip row at the top (or + New to save the current setup as a named profile — folder + model + flags + startup commands + env vars), type/paste a Windows path or browse the tree, optionally pick a model (Opus / Sonnet / Haiku), tap chips for common options (Respond in Hebrew, High effort, Auto-accept file edits, Read-only, Don't fail if Opus is busy, Confirm before changes), and add startup slash commands like /voicemode:converse that get typed into Claude after it opens.

Launchpad CLI vs Kivun Terminal — which one?

There are two projects in this family. Pick whichever fits how you work:

Launchpad CLI (this repo)Kivun Terminal wsl
Live status bar (model, context %, usage)✅✅
Light-blue Kivun theme✅ Windows Terminal✅ Konsole
Right-click "Open with..." on a folder✅ Windows Explorer✅ Windows Explorer + Linux file managers
Folder picker dialog with model + flag chips✅✅
Named profiles per project (folder + model + flags + env vars + startup slash-commands)✅ v2.6.0✅ v1.4.0
Hebrew / Arabic / Persian text right-aligned✅ in Claude's replies, via the bundled rtl-terminal plugin (Windows)✅ whole screen
English/code mixed inside a Hebrew sentence✅ in Claude's replies (Windows)✅
Supported RTL languagesAll RTL scripts in replies (Windows); macOS: LTR only11 (Hebrew, Arabic, Persian, Urdu, Pashto, Kurdish, Dari, Uyghur, Sindhi, Yiddish, Syriac)
Startup time~2 s~6 s
Install size on Windows~150 MB~2 GB (includes Ubuntu + Konsole via WSL2)
Windows supportNative (Windows Terminal)WSL2 + Ubuntu + Konsole
macOS support✅❌ Deprecated as of v1.2.4 (no native Mac terminal handles mixed Hebrew + English)
Linux support❌✅ apt / dnf / pacman / zypper

Pick Launchpad CLI if you want the lightest, fastest install. On Windows it now right-aligns Hebrew, Arabic and other RTL replies too. Pick Kivun Terminal if you're on Linux, or you need RTL across the whole screen (tool output, dialogs, the input box) and clean mouse copy of Hebrew text.

Hebrew and Arabic replies on Windows

New in 3.2.0: Claude's replies in Hebrew, Arabic, Persian, Urdu and the other right-to-left languages now read right to left in Windows Terminal. They sit against the right edge, bullets and numbers move to the right, and brackets face the right way. This comes from the bundled rtl-terminal plugin and is on after you install or upgrade. Type /rtl off inside Claude to turn it off.

Real screenshots from two Windows Terminal windows of the same size, Launchpad 3.1.0 above and 3.2.0 below.

A real answer in Hebrew. Before, lines that start with an English word came out scrambled and the list numbers sat at the wrong end.

<img src="assets/rtl/rtl-conversation.png" width="760" alt="A Hebrew answer about Git and GitHub: left-aligned and partly scrambled in Launchpad 3.1.0, right-aligned with numbers on the right in 3.2.0">

Bullets and nested bullets.

<img src="assets/rtl/rtl-bullets.png" width="760" alt="A Hebrew bullet list: bullets on the left and English-first lines scrambled in 3.1.0, bullets on the right and every line in order in 3.2.0">

Brackets, dates and prices.

<img src="assets/rtl/rtl-brackets.png" width="760" alt="Hebrew text with a version number, a date, a percentage and a price: brackets face backwards in 3.1.0 and the right way in 3.2.0">

A preview while you type. The input box itself stays as Claude Code draws it, so the plugin shows above it how your message will read.

<img src="assets/rtl/rtl-preview.png" width="760" alt="A Hebrew draft in the input box: no preview in 3.1.0, a right-aligned preview line above the input box in 3.2.0">

Quick Start

Windows

  1. Download ClaudeCode_Launchpad_CLI_Setup.exe
  2. Run as Administrator - the wizard auto-detects what's already installed
  3. Double-click the "ClaudeCode Launchpad CLI" desktop shortcut
  4. Start coding with Claude

SmartScreen / antivirus note: the installer is code-signed by its verified publisher. A brand-new signature still builds Microsoft SmartScreen reputation over time, so for a short while you may still see "Windows protected your PC" (click More info → Run anyway), and some antivirus (e.g. McAfee) may warn. This is a false positive. Launchpad CLI is open-source (MIT) with auditable source, installs only official tools (Node.js, Git, Windows Terminal, and Claude via Anthropic's official installer), and deliberately avoids the download/elevation tricks antivirus watches for. You can scan the file yourself on VirusTotal. More detail in TROUBLESHOOTING.

macOS

  1. Download the .pkg installer
  2. Double-click it, allow in System Settings > Privacy & Security, then run again
  3. Open Terminal and type claude
  4. Start coding with Claude

First time? You'll need a Claude Pro/Max subscription or Anthropic API key.

Status Bar

A two-line live status bar at the bottom of every session:

BookWriter | 🟢 Sonnet 4.6 | Context 🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ 51% | tokens: 284K | 24:13

Session 🟨🟨🟨🟨🟨🟨🟨🟨⬜⬜ 77% resets in 4h15m &nbsp;|&nbsp; Weekly 🟩🟩⬜⬜⬜⬜⬜⬜⬜⬜ 16% resets in 6d18h

FieldWhat it shows
ModelActive Claude model (color-coded: green = Opus, yellow = Sonnet/Haiku)
Context% of context window consumed (green/yellow/red)
TokensCombined input + output tokens this session
Session / WeeklyUsage limit % with countdown to reset

Voice Alerts

Short spoken clips so you don't have to watch the screen. Set up automatically and on by default — each tied to the moment that actually means it:

AlertPlays whenEvent
doneClaude has genuinely finished — nothing left to doon-demand (Claude runs it)
permissionA genuine allow this tool? request (a file edit, a command)PermissionRequest — real tool permissions only; question boxes & plan approval play waiting instead
waitingClaude has been waiting on you (~60s idle), or a question / plan-approval prompt is upNotification (idle) + reclassified prompts
saveManual intervention — act by handon-demand

Regular or Funny mode — every alert has a plain recording and a joke one (e.g. done: "Done." vs "Done. I'll pretend that took effort."). Switch with Regular Sounds ON / Funny Sounds ON or node ~/.claude/sounds/voice.js mode regular|funny.

An optional repeat reminder (off by default) re-plays the waiting clip every couple of minutes once you've gone idle, until you respond. Playback uses Windows Media Player on Windows (no PowerShell) and afplay on macOS — no Python, no extra installs.

Controls: double-click Sound ON/OFF, Regular/Funny Sounds ON, Test Sounds in ~/.claude/sounds/, or node ~/.claude/sounds/voice.js on|off|mode <m>|repeat on|off|status. Full details: ~/.claude/sounds/README.md.

Turn all voice alerts off (one global switch)

The on/off setting is global — a single switch for every project, every folder, and every window. It is not per-project and not per-profile. Turning it off silences all of it: the four alerts and the repeat reminder.

Two ways to do it, no commands needed:

  1. In the launcher's picker — open the launcher, expand Advanced options → 🔊 Sound alerts, and set Sounds: Off. It saves the instant you click, so you can just close the window; you don't have to start a session.
  2. Double-click Sound OFF.cmd in C:\Users\<you>\.claude\sounds.

When does it take effect? Immediately — in every window that's already open, with no restart. (The setting is re-read before every sound, so the very next alert obeys it.) It also survives updates and reinstalls, so once it's off it stays off until you set it back to On the same way. Nothing is removed or uninstalled — it's a reversible switch.

Tech Stack

ComponentTechnologyPurpose
Windows installerNSISSilent/wizard install with dependency detection
macOS installerpkgbuild.pkg with postinstall script via Homebrew
LauncherBatch / ShellFolder picker, flag passing, WT/CMD fallback
Terminal profileWindows Terminal JSON FragmentCustom "Noam" color scheme (#C8E6FF)
Status barNode.js (statusline.mjs)Live model, context, and usage display
Config scriptsNode.jsWT settings injection, statusline setup
CI/CDGitHub ActionsAutomated macOS .pkg builds

Configuration

Set the terminal color right in the folder picker → Advanced options (with an Apply now button that recolors Windows Terminal instantly), or edit %LOCALAPPDATA%\Kivun\config.txt:

RESPONSE_LANGUAGE=english     # 24+ languages supported
TERMINAL_COLOR=kivun          # kivun / dark / black / white / default, or a hex like #1e1e2e
CLAUDE_FLAGS=                 # e.g. --continue
AUTO_CONTINUE=false           # auto-type "continue" when the 5-hour limit resets (see below)

Auto-continue after the 5-hour limit resets (opt-in)

When Claude Code hits the 5-hour usage cap, the session stays alive but idle until you come back and type something after the limit resets. Turn on Auto-continue when limit resets (folder picker → Advanced options, or set AUTO_CONTINUE=true in config.txt) and a small background watcher waits until the limit's real reset time passes, focuses this tab, and resumes your work once so it continues on its own. By default it doesn't type a bare continue — it asks Claude to re-check its own git/file state first (see Mid-edit resume below).

This does not bypass the limit — it waits for the real reset time and then resumes. It is off by default, conservative, and capped:

AUTO_CONTINUE=false            # master switch (off by default)
AUTO_CONTINUE_MAX=5            # most times it will auto-continue in one run, then stop
AUTO_CONTINUE_FALLBACK_MIN=300 # if blocked with no known reset time, wait this many minutes
AUTO_CONTINUE_QUIET=           # optional quiet hours "HH:MM-HH:MM" (local); never fire inside
AUTO_CONTINUE_SAFE_RESUME=true # re-check git/file state before resuming (see Mid-edit resume)

Caveats — read before turning it on:

  • Resumes only. Your PC must be awake (Settings → Power: sleep = Never while you use this), the tab still open, and Claude still running. It does not restart a closed tab.
  • It briefly steals focus at reset time to type continue. If you happen to be typing in another app at that exact moment, the word may land there. At 3am this is theoretical.
  • It can't see the screen. If a permission prompt is showing at reset time, the keystrokes land in that prompt. Auto-accept file edits (--permission-mode acceptEdits) helps, but it only auto-accepts file-edit prompts — a pending command or tool prompt still gets the keys.
  • It focuses by the project's folder-name title, not an exact tab. With several projects open it can't pick which one receives the keys — and because Windows matches window titles by prefix, a folder name that's a prefix of another (e.g. Kivun vs Kivun_all) could send continue to the wrong project's window. It's opt-in partly for this reason.
  • Mid-edit resume. If the session paused while a file edit was in flight, a naive continue can make Claude redo work it already did — or re-apply a half-written patch (people have seen a function get duplicated this way). AUTO_CONTINUE_SAFE_RESUME=true (the default) guards this by asking Claude to run git status and re-read the file it was editing before continuing, so an edit that already landed is reconciled instead of repeated. It's a genuine mitigation, not a guarantee — the real safety net is to commit (or git add) before you leave it running and prefer whole-function edits, so a duplicate shows up in git status instead of shipping. Set it to false for the old plain continue.
  • No final-render gap. A session that blocks without one last status-bar update won't auto-continue (known v1 limitation).
  • Terms of Service. Unattended automated continuation may violate the provider's usage policy and can spend quota on unwanted work. Defaults are conservative; use at your own risk.

Contributing

Contributions are welcome! Areas where help is especially useful:

  • Installer testing -- different Windows/macOS versions and locales
  • Windows on ARM -- the NSIS installer is x64-only today
  • macOS notarization -- the .pkg is currently unsigned; users on stricter Gatekeeper settings have to right-click → Open

Looking for Linux, or RTL across the whole screen? Use the sister project kivun-terminal-wsl — Windows-via-WSL + Linux installers with full BiDi rendering.

Fork the repo, make your changes, and open a PR.

Community

Submitted to awesome lists (pending review):

Credits

Right-to-left layout in Claude's replies comes from rtl-terminal by MohammedSaud404 (MIT), bundled unmodified in source/plugins/rtl-terminal/ with its license. Turn it off any time with /rtl off inside Claude.

License

MIT


<strong>Made by <a href="https://github.com/noambrand">Noam Brand</a></strong> <a href="https://github.com/noambrand"><img src="https://img.shields.io/badge/GitHub-noambrand-181717?logo=github" alt="GitHub"></a> <a href="https://www.linkedin.com/in/noambrand/"><img src="https://img.shields.io/badge/LinkedIn-noambrand-0A66C2?logo=linkedin&logoColor=white" alt="LinkedIn"></a> <a href="https://www.facebook.com/noambbb/"><img src="https://img.shields.io/badge/Facebook-noambbb-1877F2?logo=facebook&logoColor=white" alt="Facebook"></a> <a href="mailto:noambbb@gmail.com"><img src="https://img.shields.io/badge/Email-noambbb%40gmail.com-EA4335?logo=gmail&logoColor=white" alt="Email"></a>

Source 16 files
hooks/register.tsx 292 lines
1import type { Elements as SurfaceElements, EngineInterface, Register, RenderElement } from 'claude-code'
2
3import { LRM } from './bidi'
4import { itemMarker, layoutRtl, parseInline, splitBlocks, writeOutLinks } from './markdown'
5import type { Block, RtlBlock, Span } from './markdown'
6import { layoutPlainText, needsPreview, previewRows } from './preview'
7import { asMode, hasHyperlinks, reorderedBy } from './target'
8import type { Mode } from './target'
9
10// Cells the transcript keeps left of a reply's text for its `●` mark.
11const MESSAGE_GUTTER = 2
12// A cell kept free at the right, so a scrollbar or a rounding never wraps
13// a right-aligned row.
14const RIGHT_MARGIN = 1
15// Cells a prompt row of the transcript keeps around its text: the `❯ ` mark,
16// the box fullscreen draws it in, and room to spare, so the rows wrapped here
17// are never wrapped again.
18const PROMPT_ROW_CHROME = 6
19// Drawn dim at the right of a draft's preview, where a list keeps its bullet.
20const PREVIEW_GUTTER = ' ✎'
21// How often a shown preview checks the prompt box: clearing it (Ctrl+C, Esc,
22// history) raises no edit, and the preview must not outlive the draft.
23const PREVIEW_CHECK_MS = 300
24
25type Elements = SurfaceElements['terminal']
26
27function spanElement(els: Elements, span: Span, hasLinks: boolean) {
28  const { Link, Text } = els
29  const props: Record<string, unknown> = {}
30  if (span.bold) props.bold = true
31  if (span.italic) props.italic = true
32  if (span.underline) props.underline = true
33  if (span.strike) props.strikethrough = true
34  if (span.code) props.color = 'permission'
35  if (span.href && !span.code) {
36    if (!hasLinks) return <Text {...props} color="blueBright">{span.text}</Text>
37    return (
38      <Text {...props} color="blueBright">
39        <Link href={span.href}>{span.text}</Link>
40      </Text>
41    )
42  }
43  return <Text {...props}>{span.text}</Text>
44}
45
46function gutterOf(block: RtlBlock): { text: string; isDim: boolean } {
47  if (block.role === 'item') {
48    return { text: ` ${itemMarker(block.marker, block.depth)}${'  '.repeat(block.depth)}`, isDim: false }
49  }
50  if (block.role === 'quote') return { text: ' ▎', isDim: true }
51  return { text: '', isDim: false }
52}
53
54function blockRows(els: Elements, block: RtlBlock, width: number, hasLinks: boolean) {
55  const { Box, Text } = els
56  const gutter = gutterOf(block)
57  const room = width - gutter.text.length
58  const style: Omit<Span, 'text'> =
59    block.role === 'heading'
60      ? block.headingLevel === 1
61        ? { bold: true, italic: true, underline: true }
62        : { bold: true }
63      : block.role === 'quote'
64        ? { italic: true }
65        : {}
66  const rows = block.lines.flatMap(line => {
67    const spans = parseInline(line, style)
68    return layoutRtl(hasLinks ? spans : writeOutLinks(spans), room)
69  })
70
71  return rows.map((row, r) => {
72    const mark = r === 0 || block.role === 'quote' ? gutter.text : ' '.repeat(gutter.text.length)
73    return (
74      <Box flexDirection="row">
75        <Text>{row.map(span => spanElement(els, span, hasLinks))}</Text>
76        {gutter.text !== '' && (
77          <Text dimColor={gutter.isDim}>{LRM + mark}</Text>
78        )}
79      </Box>
80    )
81  })
82}
83
84function drawBlocks(els: Elements, blocks: Block[], width: number, isFirstOfReply: boolean, hasLinks: boolean) {
85  const { Box, Markdown, Text } = els
86  return (
87    <Box flexDirection="row">
88      <Box width={MESSAGE_GUTTER}>
89        <Text color="text">{isFirstOfReply ? '●' : ' '}</Text>
90      </Box>
91      <Box flexDirection="column" width={width}>
92      {blocks.map((block, n) => {
93        const marginTop = block.gapBefore && n > 0 ? 1 : 0
94        if (block.kind === 'markdown') {
95          return (
96            <Box marginTop={marginTop}>
97              <Markdown text={block.source} />
98            </Box>
99          )
100        }
101        return (
102          <Box marginTop={marginTop} flexDirection="column" alignItems="flex-end">
103            {blockRows(els, block, width, hasLinks)}
104          </Box>
105        )
106      })}
107      </Box>
108    </Box>
109  )
110}
111
112function drawPreview(els: Elements, text: string, width: number, maxRows: number) {
113  const { Box, Text } = els
114  const rows = previewRows(text, width - PREVIEW_GUTTER.length).slice(-Math.max(1, maxRows))
115
116  return (
117    <Box flexDirection="column" width={width}>
118      {rows.map((row, r) => (
119        <Box flexDirection="row" alignSelf={row.isRtl ? 'flex-end' : 'flex-start'}>
120          <Text>{row.spans.map(span => spanElement(els, span, false))}</Text>
121          {row.isRtl && <Text dimColor>{LRM + (r === 0 ? PREVIEW_GUTTER : '  ')}</Text>}
122        </Box>
123      ))}
124    </Box>
125  )
126}
127
128// The band above the prompt is shared with the engine and other plugins: what
129// they draw there stays, and the preview goes under it, next to the prompt.
130export function drawBand(els: Elements, beneath: RenderElement, text: string, width: number, maxRows: number) {
131  const { Box } = els
132
133  return (
134    <Box flexDirection="column">
135      {beneath}
136      {drawPreview(els, text, width, maxRows)}
137    </Box>
138  )
139}
140
141const HELP = [
142  'Usage: /rtl [on | off]       RTL layout of replies',
143  '       /rtl input [on | off] live preview of an RTL draft above the prompt',
144  '       /rtl mode [auto | claude | terminal]',
145  '                             who reorders RTL rows: claude is Claude Code',
146  '                             itself (Windows Terminal, conhost, VS Code), where',
147  '                             the plugin lays them out; terminal is the',
148  "                             terminal's own engine (Linux, macOS), left to it;",
149  '                             auto works it out',
150].join('\n')
151
152// Who reorders rows in this session, from the stored mode and the environment.
153async function resolveReorderedBy($: EngineInterface, mode: Mode): Promise<'claude' | 'terminal'> {
154  const os = await $.env.get('OS')
155  const termProgram = await $.env.get('TERM_PROGRAM')
156  return reorderedBy(mode, { os, termProgram })
157}
158
159// Whether this terminal shows clickable links, as Claude Code decides it.
160async function resolveHasLinks($: EngineInterface): Promise<boolean> {
161  return hasHyperlinks({
162    forceHyperlink: await $.env.get('FORCE_HYPERLINK'),
163    wtSession: await $.env.get('WT_SESSION'),
164    termProgram: await $.env.get('TERM_PROGRAM'),
165    terminalEmulator: await $.env.get('TERMINAL_EMULATOR'),
166  })
167}
168
169export const register: Register = on => {
170  // The session's settings, loaded from the plugin's store when the session
171  // starts (and again on every reload); a change redraws what they shape.
172  let isEnabled = true
173  let isPreviewOn = true
174  let whoReorders: 'claude' | 'terminal' = 'claude'
175  let hasLinks = false
176  let draft = ''
177
178  // Lays rows out only where Claude Code reorders them, and only when on.
179  const isLaidOut = () => isEnabled && whoReorders === 'claude'
180
181  on('session.start', async ($, e, next) => {
182    const saved = await $.store.get('isEnabled')
183    if (typeof saved === 'boolean') isEnabled = saved
184    const savedPreview = await $.store.get('isPreviewOn')
185    if (typeof savedPreview === 'boolean') isPreviewOn = savedPreview
186    whoReorders = await resolveReorderedBy($, asMode(await $.store.get('mode')))
187    hasLinks = await resolveHasLinks($)
188    $.ui.invalidate('ui.render')
189
190    await $.command.register({
191      name: 'rtl',
192      description: 'Right-to-left layout for Arabic, Hebrew, Persian, Urdu and other RTL replies',
193      argumentHint: '[input] on | off | mode auto | claude | terminal',
194    })
195
196    $.clock.every(PREVIEW_CHECK_MS, async () => {
197      if (draft === '') return
198      const box = await $.prompt.read()
199      if (box.text === draft) return
200      draft = box.text
201      $.ui.invalidate('ui.render')
202    })
203
204    return next(e)
205  })
206
207  on('command.run', { command: 'rtl' }, async ($, e) => {
208    const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
209
210    if (words[0] === 'mode') {
211      const asked = words[1]
212      if (words.length > 2 || (asked !== undefined && asMode(asked) !== asked)) return { text: HELP }
213      const mode = asMode(asked ?? (await $.store.get('mode')))
214      await $.store.set('mode', mode)
215      whoReorders = await resolveReorderedBy($, mode)
216      $.ui.invalidate('ui.render')
217      return {
218        text:
219          whoReorders === 'claude'
220            ? `RTL mode is ${mode}: Claude Code reorders RTL rows here, and the plugin lays them out.`
221            : `RTL mode is ${mode}: this terminal reorders RTL rows itself, and the plugin leaves them to it.`,
222      }
223    }
224
225    const isInput = words[0] === 'input'
226    const arg = isInput ? words[1] : words[0]
227    if ((arg !== undefined && arg !== 'on' && arg !== 'off') || words.length > (isInput ? 2 : 1)) {
228      return { text: HELP }
229    }
230
231    const choose = (current: boolean) => (arg === 'on' ? true : arg === 'off' ? false : !current)
232
233    if (isInput) {
234      isPreviewOn = choose(isPreviewOn)
235      await $.store.set('isPreviewOn', isPreviewOn)
236      $.ui.invalidate('ui.render')
237      return { text: `RTL input preview is ${isPreviewOn ? 'on' : 'off'}.` }
238    }
239
240    isEnabled = choose(isEnabled)
241    await $.store.set('isEnabled', isEnabled)
242    $.ui.invalidate('ui.render')
243    return { text: `RTL layout is ${isEnabled ? 'on' : 'off'}.` }
244  })
245
246  // Reads the draft after each edit, for the preview; the edit itself goes on
247  // unchanged.
248  on('prompt.edit', async ($, e, next) => {
249    const box = await next(e)
250    draft = box.text
251    $.ui.invalidate('ui.render')
252    return box
253  }).catch(($, e, next) => next(e))
254
255  // Clears the preview once a prompt is sent; the prompt itself goes on
256  // unchanged.
257  on('prompt.submit', async ($, e, next) => {
258    draft = ''
259    $.ui.invalidate('ui.render')
260    return next(e)
261  }).catch(($, e, next) => next(e))
262
263  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
264    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
265    if (!isLaidOut() || !isPreviewOn) return next(e)
266    if (!needsPreview(draft, e.props.bodyColumns - 4)) return next(e)
267
268    const beneath = await next(e)
269    return drawBand($.ui.resolve(e), beneath, draft, e.props.bodyColumns, e.props.maxRows)
270  })
271
272  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
273    if (e.surface !== 'terminal' || !isLaidOut()) return next(e)
274
275    const blocks = splitBlocks(e.props.text)
276    if (!blocks) return next(e)
277
278    const columns = e.viewport?.columns ?? 80
279    const width = Math.max(20, columns - MESSAGE_GUTTER - RIGHT_MARGIN)
280    return drawBlocks($.ui.resolve(e), blocks, width, e.props.isFirstOfReply, hasLinks)
281  })
282
283  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
284    if (e.surface !== 'terminal' || !isLaidOut()) return next(e)
285
286    const columns = e.viewport?.columns ?? 80
287    const text = layoutPlainText(e.props.text, Math.max(20, columns - PROMPT_ROW_CHROME))
288
289    return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
290  })
291}
292
hooks/bidi.ts 206 lines
1// Character facts the layout needs: bidi strength, display width, and the
2// levels the Unicode Bidirectional Algorithm gives a right-to-left paragraph.
3
4import { getEmbeddingLevels, getMirroredCharactersMap } from './vendor/bidi-js/index.js'
5import { WIDE_RANGES } from './wide'
6
7export const RLM = '\u200F'
8export const LRM = '\u200E'
9
10// Strong right-to-left characters (bidi classes R and AL) of every RTL
11// script: Hebrew, Arabic (Persian, Urdu, Pashto, Kurdish, Sindhi, Uyghur...),
12// Syriac, Thaana, NKo, Samaritan, Mandaic, Adlam, Hanifi Rohingya, Yezidi,
13// Mende Kikakui and the old scripts of 10800-10FFF, their presentation forms,
14// and RLM. Digits and combining marks are left out: they are not strong.
15const RTL_LETTER =
16  /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u070D\u0710-\u074A\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA-\u0815\u081A\u0824\u0828\u0830-\u0858\u085E-\u088E\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFD3D\uFD50-\uFDFC\uFE70-\uFEFC\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u
17const LETTER = /[\p{L}\u200E]/u
18
19export type Strong = 'R' | 'L' | null
20
21export function strongOf(ch: string): Strong {
22  if (RTL_LETTER.test(ch)) return 'R'
23  if (LETTER.test(ch)) return 'L'
24  return null
25}
26
27export function hasRtl(text: string): boolean {
28  return RTL_LETTER.test(text)
29}
30
31export function firstStrong(text: string): Strong {
32  for (const ch of text) {
33    const dir = strongOf(ch)
34    if (dir) return dir
35  }
36  return null
37}
38
39export function lastStrong(text: string): Strong {
40  const chars = Array.from(text)
41  for (let i = chars.length - 1; i >= 0; i--) {
42    const dir = strongOf(chars[i] ?? '')
43    if (dir) return dir
44  }
45  return null
46}
47
48// Characters that join the cluster before them: marks, joiners, variation
49// selectors, skin tones, tag characters and the keycap.
50const EXTENDS =
51  /[\p{M}\u200C\u200D\uFE00-\uFE0F\u{1F3FB}-\u{1F3FF}\u{E0020}-\u{E007F}\u20E3]/u
52const REGIONAL = /[\u{1F1E6}-\u{1F1FF}]/u
53const NOTHING_DRAWN = /^[\p{M}\p{Cf}\p{Cc}\u2060-\u206F\uFE00-\uFE0F\uFEFF]+$/u
54// What makes a cluster an emoji the terminal draws two wide whatever its base:
55// the emoji selector, a skin tone, a joined sequence, tags or a keycap.
56const EMOJI_MARKS = /[\uFE0F\u200D\u20E3\u{1F3FB}-\u{1F3FF}\u{E0020}-\u{E007F}]/u
57const EMOJI_BASE = /[\p{Extended_Pictographic}#*0-9]/u
58// Drawn as emoji by default; covers emoji newer than the width table.
59const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u
60
61const segmenter =
62  typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
63    ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
64    : null
65
66/** The grapheme clusters of `text`: what a terminal draws as one glyph. */
67export function clusters(text: string): string[] {
68  if (segmenter) return Array.from(segmenter.segment(text), part => part.segment)
69
70  const out: string[] = []
71  let joinNext = false
72  for (const ch of text) {
73    const last = out.length - 1
74    const prev = out[last]
75    const pairsFlag =
76      prev !== undefined && REGIONAL.test(ch) && /^[\u{1F1E6}-\u{1F1FF}]$/u.test(prev)
77    if (prev !== undefined && (joinNext || EXTENDS.test(ch) || pairsFlag)) {
78      out[last] = prev + ch
79    } else {
80      out.push(ch)
81    }
82    joinNext = ch === '\u200D'
83  }
84  return out
85}
86
87function isWide(cp: number): boolean {
88  let lo = 0
89  let hi = WIDE_RANGES.length / 2 - 1
90  while (lo <= hi) {
91    const mid = (lo + hi) >> 1
92    const start = WIDE_RANGES[mid * 2] ?? 0
93    const end = WIDE_RANGES[mid * 2 + 1] ?? 0
94    if (cp < start) hi = mid - 1
95    else if (cp > end) lo = mid + 1
96    else return true
97  }
98  return false
99}
100
101/** Terminal cells one grapheme cluster takes. */
102export function clusterWidth(cluster: string): number {
103  const cp = cluster.codePointAt(0) ?? 0
104  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || NOTHING_DRAWN.test(cluster)) return 0
105  const base = String.fromCodePoint(cp)
106  if (REGIONAL.test(base)) return 2
107  if (EMOJI_BASE.test(base) && EMOJI_MARKS.test(cluster.slice(base.length))) return 2
108  return isWide(cp) || EMOJI_PRESENTATION.test(base) ? 2 : 1
109}
110
111/**
112 * Terminal cells `text` takes, as Claude Code measures it: marks and format
113 * characters none; East Asian wide and fullwidth characters, emoji drawn as
114 * emoji (by default or by the emoji selector), skin-toned and joined
115 * sequences, flags and keycaps two; the rest one.
116 */
117export function widthOf(text: string): number {
118  let width = 0
119  for (const cluster of clusters(text)) width += clusterWidth(cluster)
120  return width
121}
122
123// Pictographs a terminal draws from a color emoji font two cells wide even
124// when Unicode says they are one: Miscellaneous Symbols, Dingbats, arrows and
125// stars of 2B00, and the emoji planes. Given the emoji selector, they are laid
126// out two wide too, and stop overlapping what follows.
127const PICTOGRAPH_BLOCKS = /[\u2600-\u27BF\u2B00-\u2BFF\u{1F000}-\u{1FAFF}]/u
128const PICTOGRAPH = /\p{Extended_Pictographic}/u
129
130/** `text` with every lone text-style pictograph given the emoji selector. */
131export function asEmoji(text: string): string {
132  if (!PICTOGRAPH_BLOCKS.test(text)) return text
133  return clusters(text)
134    .map(cluster => {
135      const base = String.fromCodePoint(cluster.codePointAt(0) ?? 0)
136      const isTextStyle =
137        cluster === base &&
138        PICTOGRAPH_BLOCKS.test(base) &&
139        PICTOGRAPH.test(base) &&
140        !EMOJI_PRESENTATION.test(base)
141      return isTextStyle ? `${base}\uFE0F` : cluster
142    })
143    .join('')
144}
145
146const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
147const CLOSERS: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
148
149// Where a word begins or ends: a space, an end of the text, or (after a
150// closing bracket) punctuation.
151const BREAK = /\s/
152const AFTER_CLOSE = /[\s.,:;!?\u060C\u061B\u061F]/
153
154/**
155 * Turns each bracket that pairs with nothing the way its place says, as RTL
156 * keyboard layouts often type them backwards (`)x(`, `(x(`, `)x)`): one
157 * between a break and a word opens, one between a word and a break closes;
158 * one with a word or a break on both sides stays as it is (`1)`, `:)`).
159 * `editable` marks the characters that may change (code and links are left as
160 * written); `chars` is changed in place.
161 */
162export function fixTypedBrackets(chars: string[], editable: boolean[]): void {
163  const matched = new Set<number>()
164  const stack: { at: number; close: string }[] = []
165  chars.forEach((ch, i) => {
166    if (!editable[i]) return
167    const close = OPENERS[ch]
168    if (close) {
169      stack.push({ at: i, close })
170      return
171    }
172    for (let s = stack.length - 1; s >= 0; s--) {
173      const open = stack[s]
174      if (open?.close === ch) {
175        matched.add(open.at)
176        matched.add(i)
177        stack.length = s
178        break
179      }
180    }
181  })
182
183  chars.forEach((ch, i) => {
184    if (!editable[i] || matched.has(i)) return
185    const before = chars[i - 1]
186    const after = chars[i + 1]
187    const opensWord = (before === undefined || BREAK.test(before)) && after !== undefined && !BREAK.test(after)
188    const closesWord =
189      before !== undefined && !BREAK.test(before) && (after === undefined || AFTER_CLOSE.test(after))
190    const opener = CLOSERS[ch]
191    const closer = OPENERS[ch]
192    if (opener && opensWord && !closesWord) chars[i] = opener
193    else if (closer && closesWord && !opensWord) chars[i] = closer
194  })
195}
196
197/**
198 * The levels the full Unicode Bidirectional Algorithm gives each UTF-16 unit
199 * of a paragraph of base direction RTL (odd right-to-left, even
200 * left-to-right), and the characters those levels draw mirrored.
201 */
202export function resolveRtl(text: string): { levels: Uint8Array; mirrors: Map<number, string> } {
203  const embedding = getEmbeddingLevels(text, 'rtl')
204  return { levels: embedding.levels, mirrors: getMirroredCharactersMap(text, embedding.levels) }
205}
206
hooks/markdown.ts 601 lines
1// Splits a reply's markdown into blocks, decides which read right-to-left,
2// and lays those out as rows: inline styles parsed, levels resolved by the
3// Unicode Bidirectional Algorithm, words wrapped to a width, and each row
4// pinned so the terminal's renderer draws exactly those levels.
5
6import {
7  LRM,
8  RLM,
9  asEmoji,
10  clusters,
11  firstStrong,
12  fixTypedBrackets,
13  hasRtl,
14  resolveRtl,
15  widthOf,
16} from './bidi'
17
18export type Span = {
19  text: string
20  bold?: boolean
21  italic?: boolean
22  underline?: boolean
23  strike?: boolean
24  code?: boolean
25  href?: string
26  // The bidi level the paragraph gives this text, once resolved.
27  level?: number
28}
29
30export type RtlBlock = {
31  kind: 'rtl'
32  role: 'paragraph' | 'heading' | 'item' | 'quote'
33  // Each source line starts a row of its own, as the terminal draws them.
34  lines: string[]
35  depth: number
36  marker: string
37  headingLevel: number
38  gapBefore: boolean
39}
40
41export type MarkdownBlock = { kind: 'markdown'; source: string; gapBefore: boolean }
42
43export type Block = RtlBlock | MarkdownBlock
44
45const FENCE = /^\s{0,3}(`{3,}|~{3,})/
46const TABLE_ROW = /^\s*\|/
47const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/
48const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/
49const QUOTE = /^\s{0,3}>\s?(.*)$/
50const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
51const HTML_BLOCK = /^\s{0,3}<\/?[a-zA-Z][^>]*>\s*$/
52
53type Parsed =
54  | { kind: 'markdown'; source: string[]; gapBefore: boolean }
55  | {
56      kind: 'text'
57      role: RtlBlock['role']
58      source: string[]
59      lines: string[]
60      depth: number
61      marker: string
62      headingLevel: number
63      gapBefore: boolean
64    }
65
66function startsBlock(line: string): boolean {
67  return (
68    FENCE.test(line) ||
69    TABLE_ROW.test(line) ||
70    RULE.test(line) ||
71    HEADING.test(line) ||
72    QUOTE.test(line) ||
73    ITEM.test(line)
74  )
75}
76
77function parseBlocks(markdown: string): Parsed[] {
78  const lines = markdown.split('\n')
79  const blocks: Parsed[] = []
80  let gap = false
81  let i = 0
82  const take = () => {
83    const was = gap
84    gap = false
85    return was
86  }
87
88  while (i < lines.length) {
89    const line = lines[i] ?? ''
90    if (line.trim() === '') {
91      gap = blocks.length > 0
92      i++
93      continue
94    }
95
96    const fence = FENCE.exec(line)?.[1]
97    if (fence) {
98      const source = [line]
99      i++
100      while (i < lines.length) {
101        const next = lines[i] ?? ''
102        source.push(next)
103        i++
104        const close = FENCE.exec(next)?.[1]
105        if (close && close[0] === fence[0] && close.length >= fence.length) break
106      }
107      blocks.push({ kind: 'markdown', source, gapBefore: take() })
108      continue
109    }
110
111    if (TABLE_ROW.test(line)) {
112      const source: string[] = []
113      while (i < lines.length && TABLE_ROW.test(lines[i] ?? '')) source.push(lines[i++] ?? '')
114      blocks.push({ kind: 'markdown', source, gapBefore: take() })
115      continue
116    }
117
118    if (RULE.test(line) || HTML_BLOCK.test(line)) {
119      blocks.push({ kind: 'markdown', source: [line], gapBefore: take() })
120      i++
121      continue
122    }
123
124    const heading = HEADING.exec(line)
125    if (heading) {
126      blocks.push({
127        kind: 'text',
128        role: 'heading',
129        source: [line],
130        lines: [heading[2] ?? ''],
131        depth: 0,
132        marker: '',
133        headingLevel: heading[1]?.length ?? 1,
134        gapBefore: take(),
135      })
136      i++
137      continue
138    }
139
140    if (QUOTE.test(line)) {
141      const source: string[] = []
142      const body: string[] = []
143      while (i < lines.length && QUOTE.test(lines[i] ?? '')) {
144        source.push(lines[i] ?? '')
145        body.push(QUOTE.exec(lines[i] ?? '')?.[1] ?? '')
146        i++
147      }
148      blocks.push({
149        kind: 'text',
150        role: 'quote',
151        source,
152        lines: body,
153        depth: 0,
154        marker: '',
155        headingLevel: 0,
156        gapBefore: take(),
157      })
158      continue
159    }
160
161    const item = ITEM.exec(line)
162    if (item) {
163      const indent = widthOf((item[1] ?? '').replace(/\t/g, '    '))
164      const source = [line]
165      const body = [item[3] ?? '']
166      i++
167      while (i < lines.length) {
168        const next = lines[i] ?? ''
169        if (next.trim() === '' || startsBlock(next)) break
170        source.push(next)
171        body.push(next.trim())
172        i++
173      }
174      blocks.push({
175        kind: 'text',
176        role: 'item',
177        source,
178        lines: body,
179        depth: Math.floor(indent / 2),
180        marker: item[2] ?? '-',
181        headingLevel: 0,
182        gapBefore: take(),
183      })
184      continue
185    }
186
187    const source: string[] = []
188    while (i < lines.length) {
189      const next = lines[i] ?? ''
190      if (next.trim() === '' || (source.length > 0 && startsBlock(next))) break
191      source.push(next)
192      i++
193    }
194    blocks.push({
195      kind: 'text',
196      role: 'paragraph',
197      source,
198      lines: source.map(l => l.trim()),
199      depth: 0,
200      marker: '',
201      headingLevel: 0,
202      gapBefore: take(),
203    })
204  }
205  return blocks
206}
207
208// What a reader sees of a text: code spans, link targets, tags and URLs out.
209function visibleText(text: string): string {
210  return text
211    .replace(/(`+)[^`]*?\1/g, ' ')
212    .replace(/\]\([^)]*\)/g, ']')
213    .replace(/<[^>\s]+>/g, ' ')
214    .replace(/(?:https?:\/\/|www\.)\S+/g, ' ')
215}
216
217function countWords(text: string): { rtl: number; ltr: number } {
218  const count = { rtl: 0, ltr: 0 }
219  for (const word of visibleText(text).split(/\s+/)) {
220    const dir = firstStrong(word)
221    if (dir === 'R') count.rtl++
222    else if (dir === 'L') count.ltr++
223  }
224  return count
225}
226
227/**
228 * Whether a paragraph reads right-to-left: it opens with an RTL letter, as a
229 * browser's `dir="auto"` decides, or at least a third of its words are RTL,
230 * or it holds any RTL word in a reply that is RTL as a whole.
231 */
232export function isRtlText(text: string, isRtlContext = false): boolean {
233  const visible = visibleText(text)
234  const { rtl, ltr } = countWords(visible)
235  if (rtl === 0) return false
236  return firstStrong(visible) === 'R' || isRtlContext || rtl * 2 >= ltr
237}
238
239function dedent(lines: string[]): string[] {
240  const indents = lines.filter(l => l.trim() !== '').map(l => /^\s*/.exec(l)?.[0].length ?? 0)
241  const min = Math.min(...indents)
242  return min > 0 && Number.isFinite(min) ? lines.map(l => l.slice(min)) : lines
243}
244
245/**
246 * The blocks of `markdown`: right-to-left prose as `rtl` blocks, and every
247 * stretch of anything else (code, tables, English prose) as one `markdown`
248 * block to draw as the terminal always does. Null when nothing reads RTL.
249 */
250export function splitBlocks(markdown: string): Block[] | null {
251  if (!hasRtl(markdown)) return null
252  const parsed = parseBlocks(markdown)
253  const prose = parsed.flatMap(b => (b.kind === 'text' ? b.lines : []))
254  const total = countWords(prose.join('\n'))
255  const isRtlMessage = total.rtl > 0 && total.rtl * 2 >= total.ltr
256
257  const blocks: Block[] = []
258  let pending: { lines: string[]; gapBefore: boolean } | null = null
259  const flush = () => {
260    if (pending) blocks.push({ kind: 'markdown', source: dedent(pending.lines).join('\n'), gapBefore: pending.gapBefore })
261    pending = null
262  }
263  for (const block of parsed) {
264    if (block.kind === 'text' && isRtlText(block.lines.join(' '), isRtlMessage)) {
265      flush()
266      blocks.push({
267        kind: 'rtl',
268        role: block.role,
269        lines: block.lines,
270        depth: block.depth,
271        marker: block.marker,
272        headingLevel: block.headingLevel,
273        gapBefore: block.gapBefore,
274      })
275      continue
276    }
277    if (pending) {
278      if (block.gapBefore) pending.lines.push('')
279      pending.lines.push(...block.source)
280    } else {
281      pending = { lines: [...block.source], gapBefore: block.gapBefore }
282    }
283  }
284  flush()
285  return blocks.some(b => b.kind === 'rtl') ? blocks : null
286}
287
288const ESCAPABLE = /^\\([\\`*_{}[\]()#+\-.!~|<>])/
289const CODE = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/
290const STRONG = /^(?:\*\*(?=\S)([\s\S]*?\S)\*\*|__(?=\S)([\s\S]*?\S)__(?![\p{L}\p{N}]))/u
291const STRIKE = /^~~(?=\S)([\s\S]*?\S)~~/
292const EMPHASIS = /^(?:\*(?=[^\s*])([\s\S]*?[^\s*])\*(?!\*)|_(?=\S)([\s\S]*?\S)_(?![\p{L}\p{N}]))/u
293const LINK = /^\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/
294const AUTOLINK = /^<((?:https?|mailto):[^>\s]+)>/
295const BARE_URL = /^(?:https?:\/\/|www\.)[^\s<]*[^\s<.,:;"')\]!?،؛؟]/
296
297type Style = Omit<Span, 'text'>
298
299/** The styled spans of one line of inline markdown. */
300export function parseInline(src: string, style: Style = {}): Span[] {
301  const out: Span[] = []
302  let buf = ''
303  const flush = () => {
304    if (buf) out.push({ ...style, text: buf })
305    buf = ''
306  }
307  let i = 0
308  while (i < src.length) {
309    const rest = src.slice(i)
310    const prev = i > 0 ? (src[i - 1] ?? '') : ''
311    let m: RegExpExecArray | null
312
313    if ((m = ESCAPABLE.exec(rest))) {
314      buf += m[1]
315      i += m[0].length
316      continue
317    }
318    if ((m = CODE.exec(rest))) {
319      flush()
320      const body = m[2] ?? ''
321      const text = /^ .* $/.test(body) && body.trim() !== '' ? body.slice(1, -1) : body
322      out.push({ ...style, code: true, text })
323      i += m[0].length
324      continue
325    }
326    if ((m = STRONG.exec(rest))) {
327      flush()
328      out.push(...parseInline(m[1] ?? m[2] ?? '', { ...style, bold: true }))
329      i += m[0].length
330      continue
331    }
332    if ((m = STRIKE.exec(rest))) {
333      flush()
334      out.push(...parseInline(m[1] ?? '', { ...style, strike: true }))
335      i += m[0].length
336      continue
337    }
338    if ((rest[0] === '*' || !/[\p{L}\p{N}]/u.test(prev)) && (m = EMPHASIS.exec(rest))) {
339      flush()
340      out.push(...parseInline(m[1] ?? m[2] ?? '', { ...style, italic: true }))
341      i += m[0].length
342      continue
343    }
344    if ((m = LINK.exec(rest))) {
345      flush()
346      const label = m[1] ?? ''
347      out.push(...parseInline(label === '' ? (m[2] ?? '') : label, { ...style, href: m[2] ?? '' }))
348      i += m[0].length
349      continue
350    }
351    if ((m = AUTOLINK.exec(rest))) {
352      flush()
353      out.push({ ...style, href: m[1] ?? '', text: m[1] ?? '' })
354      i += m[0].length
355      continue
356    }
357    if ((prev === '' || /[\s(]/.test(prev)) && (m = BARE_URL.exec(rest))) {
358      flush()
359      out.push({ ...style, href: m[0], text: m[0] })
360      i += m[0].length
361      continue
362    }
363    buf += src[i]
364    i++
365  }
366  flush()
367  return out
368}
369
370/**
371 * The spans of a line for a terminal that can't show clickable links, written
372 * out as Claude Code's own markdown writes them there: a link's URL after its
373 * text, in brackets, unless the text is the URL itself.
374 */
375export function writeOutLinks(spans: Span[]): Span[] {
376  const out: Span[] = []
377  let label = ''
378  spans.forEach((span, n) => {
379    out.push(span)
380    const href = span.href
381    if (!href) return
382    label += span.text
383    if (spans[n + 1]?.href === href) return
384    if (label !== href) {
385      const style = { bold: span.bold, italic: span.italic, underline: span.underline, strike: span.strike }
386      out.push({ ...style, text: ' ' }, { ...style, href, text: `(${href})` })
387    }
388    label = ''
389  })
390  return out
391}
392
393function sameStyle(a: Span, b: Span): boolean {
394  return (
395    !!a.bold === !!b.bold &&
396    !!a.italic === !!b.italic &&
397    !!a.underline === !!b.underline &&
398    !!a.strike === !!b.strike &&
399    !!a.code === !!b.code &&
400    a.href === b.href &&
401    a.level === b.level
402  )
403}
404
405function merge(spans: Span[]): Span[] {
406  const out: Span[] = []
407  for (const span of spans) {
408    const last = out[out.length - 1]
409    if (last && sameStyle(last, span)) last.text += span.text
410    else out.push({ ...span })
411  }
412  return out
413}
414
415// Cuts a word wider than a row into pieces that fit, never inside a cluster.
416function splitWide(piece: Span, width: number): Span[] {
417  const out: Span[] = []
418  let text = ''
419  for (const ch of clusters(piece.text)) {
420    if (text !== '' && widthOf(text + ch) > width) {
421      out.push({ ...piece, text })
422      text = ''
423    }
424    text += ch
425  }
426  if (text !== '') out.push({ ...piece, text })
427  return out
428}
429
430/** Wraps spans into rows no wider than `width` cells, breaking at spaces. */
431export function wrapSpans(spans: Span[], width: number): Span[][] {
432  const room = Math.max(1, width)
433  const pieces = spans.flatMap(span =>
434    span.text
435      .split(/(\s+)/)
436      .filter(text => text !== '')
437      .map(text => ({ ...span, text })),
438  )
439  const rows: Span[][] = []
440  let row: Span[] = []
441  let used = 0
442  const push = () => {
443    while (row.length > 0 && /^\s+$/.test(row[row.length - 1]?.text ?? '')) row.pop()
444    rows.push(merge(row))
445    row = []
446    used = 0
447  }
448  for (const piece of pieces) {
449    const isSpace = /^\s+$/.test(piece.text)
450    if (isSpace) {
451      if (row.length === 0) continue
452      const w = widthOf(piece.text)
453      if (used + w > room) push()
454      else {
455        row.push({ ...piece, text: ' ' })
456        used += 1
457      }
458      continue
459    }
460    const w = widthOf(piece.text)
461    if (used + w <= room) {
462      row.push(piece)
463      used += w
464      continue
465    }
466    if (row.length > 0) push()
467    if (w <= room) {
468      row.push(piece)
469      used = w
470      continue
471    }
472    const parts = splitWide(piece, room)
473    parts.slice(0, -1).forEach(part => {
474      row.push(part)
475      push()
476    })
477    const lastPart = parts[parts.length - 1]
478    if (lastPart) {
479      row.push(lastPart)
480      used = widthOf(lastPart.text)
481    }
482  }
483  if (row.length > 0 || rows.length === 0) push()
484  return rows
485}
486
487/**
488 * Readies the spans of an RTL line for wrapping: bracket pairs typed
489 * backwards put the right way round, and text-style pictographs given the
490 * emoji selector so they take the two cells a terminal draws them in. Code
491 * and links are left as written.
492 */
493export function prepareSpans(spans: Span[]): Span[] {
494  const chars: string[] = []
495  const editable: boolean[] = []
496  const lengths = spans.map(span => {
497    const own = Array.from(span.text)
498    const isEditable = !span.code && !span.href
499    for (const ch of own) {
500      chars.push(ch)
501      editable.push(isEditable)
502    }
503    return own.length
504  })
505  fixTypedBrackets(chars, editable)
506
507  let at = 0
508  return spans.map((span, n) => {
509    const count = lengths[n] ?? 0
510    const text = chars.slice(at, at + count).join('')
511    at += count
512    return { ...span, text: span.code || span.href ? text : asEmoji(text) }
513  })
514}
515
516const LRI = '\u2066'
517const PDI = '\u2069'
518
519// Punctuation that opens a Latin word: a slash command (`/rtl`), a flag
520// (`--model`), a file type (`.env`) or a path (`~/notes`, `@scope/pkg`).
521const LATIN_LEAD = /(^|[\s([{\u00ab"'])([/.\-~@#$]+)(?=[A-Za-z])/g
522// Stands in for code and links when looking for those words, so punctuation
523// right after them never counts as opening one.
524const HELD_OUT = '\uFFFC'
525
526// Opens a row with RLM and puts each run between two marks of its level's
527// direction, RLM for odd and LRM for even: a renderer that knows only strong
528// characters then has nothing of its own left to resolve.
529function pinRow(row: Span[]): Span[] {
530  const pinned = row.map(span => {
531    const mark = (span.level ?? 1) % 2 === 1 ? RLM : LRM
532    return { ...span, text: mark + span.text + mark }
533  })
534  const first = pinned[0]
535  if (first && !first.text.startsWith(RLM)) first.text = RLM + first.text
536  return pinned
537}
538
539/**
540 * Lays out one right-to-left paragraph of styled spans as rows of at most
541 * `width` cells, for a renderer that reorders each row by its strong
542 * characters alone (no embeddings, overrides or isolates) and mirrors
543 * nothing, as Claude Code's own does on Windows terminals and VS Code's.
544 *
545 * Levels come from the full Unicode Bidirectional Algorithm, resolved once
546 * for the whole paragraph as a browser does, with code and links isolated
547 * left-to-right as a browser isolates `<code>`. Characters at odd levels are
548 * drawn mirrored, and every row is pinned to those levels.
549 *
550 * Punctuation that opens a Latin word in prose (`/rtl`, `--model`, `.env`)
551 * is joined to the word: on its own the algorithm gives it the paragraph's
552 * direction and draws it on the word's far side (`rtl/`). An LRM before it,
553 * there for resolving only, keeps it on the left, where the reader expects it.
554 */
555export function layoutRtl(spans: Span[], width: number): Span[][] {
556  const prepared = prepareSpans(spans)
557  const plain = prepared
558    .map(span => (span.code || span.href ? HELD_OUT.repeat(span.text.length) : span.text))
559    .join('')
560  const leads = new Set<number>()
561  for (const match of plain.matchAll(LATIN_LEAD)) leads.add((match.index ?? 0) + (match[1]?.length ?? 0))
562
563  let paragraph = ''
564  let offset = 0
565  const places: number[][] = []
566  for (const span of prepared) {
567    const isIsolated = span.code || span.href
568    if (isIsolated) paragraph += LRI
569    const own: number[] = []
570    for (let i = 0; i < span.text.length; i++) {
571      if (leads.has(offset + i)) paragraph += LRM
572      own.push(paragraph.length)
573      paragraph += span.text[i]
574    }
575    places.push(own)
576    offset += span.text.length
577    if (isIsolated) paragraph += PDI
578  }
579  const { levels, mirrors } = resolveRtl(paragraph)
580
581  const runs: Span[] = []
582  prepared.forEach((span, n) => {
583    const own = places[n] ?? []
584    for (let i = 0; i < span.text.length; i++) {
585      const at = own[i] ?? 0
586      const unit = { ...span, text: mirrors.get(at) ?? span.text[i] ?? '', level: levels[at] ?? 1 }
587      const last = runs[runs.length - 1]
588      if (last && sameStyle(last, unit)) last.text += unit.text
589      else runs.push(unit)
590    }
591  })
592  return wrapSpans(runs, width).map(pinRow)
593}
594
595/** Marker drawn at a list item's right, in the order the eye meets it. */
596export function itemMarker(marker: string, depth: number): string {
597  const ordered = /^(\d+)[.)]$/.exec(marker)
598  if (ordered) return `.${ordered[1] ?? ''}`
599  return ['•', '◦', '▪'][depth % 3] ?? '•'
600}
601
hooks/preview.ts 54 lines
1// Plain text the person wrote, as opposed to a reply's markdown: the draft in
2// the prompt box and the prompt rows of the transcript. The box draws a draft
3// left-aligned with brackets unmirrored, each wrapped row in its own
4// direction, and its text is what gets sent, so it is left alone and
5// previewed above; a prompt row is redrawn in place, the model's copy
6// untouched.
7
8import { hasRtl, strongOf, widthOf } from './bidi'
9import { isRtlText, layoutRtl, wrapSpans } from './markdown'
10import type { Span } from './markdown'
11
12const MIRRORABLE = /[()[\]{}<>«»‹›]/
13
14/**
15 * Whether the prompt box would draw `draft` wrong at `width` cells: RTL text
16 * mixed with Latin words, holding brackets, over several lines, or wrapping.
17 */
18export function needsPreview(draft: string, width: number): boolean {
19  if (!hasRtl(draft)) return false
20  if (draft.includes('\n') || MIRRORABLE.test(draft)) return true
21  if (widthOf(draft) > width) return true
22  return Array.from(draft).some(ch => strongOf(ch) === 'L')
23}
24
25export type PreviewRow = { isRtl: boolean; spans: Span[] }
26
27/** The rows of plain `text` at `width` cells, each RTL row ready to draw. */
28export function previewRows(text: string, width: number): PreviewRow[] {
29  return text.split('\n').flatMap(line => {
30    const isRtl = isRtlText(line)
31    const rows = isRtl ? layoutRtl([{ text: line }], width) : wrapSpans([{ text: line }], width)
32    return rows.map(spans => ({ isRtl, spans }))
33  })
34}
35
36/**
37 * Plain `text` rewritten for a renderer that keeps it as one block: each RTL
38 * line laid out at `width` cells by hand, every row pinned to the levels the
39 * Unicode Bidirectional Algorithm gives it. Text with no RTL comes back
40 * unchanged.
41 */
42export function layoutPlainText(text: string, width: number): string {
43  if (!hasRtl(text)) return text
44  return text
45    .split('\n')
46    .map(line => {
47      if (!isRtlText(line)) return line
48      return layoutRtl([{ text: line }], width)
49        .map(row => row.map(span => span.text).join(''))
50        .join('\n')
51    })
52    .join('\n')
53}
54
hooks/target.ts 57 lines
1// Who reorders RTL rows once drawn, and so whether this plugin lays them out.
2//
3// Claude Code reorders RTL rows itself, by the bidi rules from strong
4// characters (RLM and LRM included), in the terminals with no bidi of their
5// own: Windows Terminal and conhost (its changelog, 2.1.74) and VS Code's
6// terminal on every system (seen in CI). There the plugin lays rows out.
7//
8// Elsewhere (Linux and macOS terminals) Claude Code writes rows in logical
9// order and the terminal's own engine reorders them; Claude Code drops RLM and
10// LRM on the way and turns isolates into U+FFFD (seen in CI), so no standard
11// direction control reaches that engine, and the plugin leaves rows to it.
12
13export type Mode = 'auto' | 'claude' | 'terminal'
14
15export type Environment = { os?: string; termProgram?: string }
16
17export function asMode(value: unknown): Mode {
18  return value === 'claude' || value === 'terminal' ? value : 'auto'
19}
20
21/** Who reorders rows in `environment` under `mode`. */
22export function reorderedBy(mode: Mode, environment: Environment): 'claude' | 'terminal' {
23  if (mode !== 'auto') return mode
24  const isWindows = environment.os === 'Windows_NT'
25  const isVsCode = environment.termProgram === 'vscode'
26  return isWindows || isVsCode ? 'claude' : 'terminal'
27}
28
29// Whether Claude Code draws links here as clickable spans (OSC 8). Where it
30// can't, its Link element prints the URL after the text, which a laid-out row
31// has no room for, so the plugin writes links out itself, as Claude Code's own
32// markdown does there.
33//
34// Claude Code's check (2.1.296), for the terminals the plugin lays out in:
35// FORCE_HYPERLINK decides when set; Windows Terminal and JetBrains' terminal
36// have links; conhost and VS Code's terminal don't. Its `hyperlinks` setting
37// isn't readable from a plugin. Unsure counts as no: a link written out stays
38// readable, while a link drawn where there are none breaks the row.
39
40export type LinkEnvironment = {
41  forceHyperlink?: string
42  wtSession?: string
43  termProgram?: string
44  terminalEmulator?: string
45}
46
47const LINKING_PROGRAMS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal']
48
49/** Whether Claude Code shows clickable links in `environment`. */
50export function hasHyperlinks(environment: LinkEnvironment): boolean {
51  const force = environment.forceHyperlink
52  if (force !== undefined && force !== '') return parseInt(force, 10) !== 0
53  if (environment.wtSession !== undefined) return true
54  if (environment.termProgram !== undefined && LINKING_PROGRAMS.includes(environment.termProgram)) return true
55  return environment.terminalEmulator === 'JetBrains-JediTerm'
56}
57
hooks/vendor/bidi-js/index.js 6 lines
1export { getEmbeddingLevels } from './embeddingLevels.js'
2export { getReorderSegments, getReorderedIndices, getReorderedString } from './reordering.js'
3export { getBidiCharType, getBidiCharTypeName } from './charTypes.js'
4export { getMirroredCharacter, getMirroredCharactersMap } from './mirroring.js'
5export { closingToOpeningBracket, openingToClosingBracket, getCanonicalBracket } from './brackets.js'
6
hooks/wide.ts 38 lines
1// Code point ranges whose East Asian Width is W or F, from the Unicode
2// Character Database 17.0.0 (EastAsianWidth.txt), merged, as start, end
3// pairs: what a terminal draws two cells wide.
4
5export const WIDE_RANGES: readonly number[] = [
6  0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec,
7  0x23f0, 0x23f0, 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615,
8  0x2630, 0x2637, 0x2648, 0x2653, 0x267f, 0x267f, 0x268a, 0x268f,
9  0x2693, 0x2693, 0x26a1, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be,
10  0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4, 0x26ea, 0x26ea,
11  0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd,
12  0x2705, 0x2705, 0x270a, 0x270b, 0x2728, 0x2728, 0x274c, 0x274c,
13  0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795, 0x2797,
14  0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50,
15  0x2b55, 0x2b55, 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5,
16  0x2ff0, 0x303e, 0x3041, 0x3096, 0x3099, 0x30ff, 0x3105, 0x312f,
17  0x3131, 0x318e, 0x3190, 0x31e5, 0x31ef, 0x321e, 0x3220, 0x3247,
18  0x3250, 0xa48c, 0xa490, 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3,
19  0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe52, 0xfe54, 0xfe66,
20  0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4,
21  0x16ff0, 0x16ff6, 0x17000, 0x18cd5, 0x18cff, 0x18d1e, 0x18d80, 0x18df2,
22  0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122,
23  0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155, 0x1b155, 0x1b164, 0x1b167,
24  0x1b170, 0x1b2fb, 0x1d300, 0x1d356, 0x1d360, 0x1d376, 0x1f004, 0x1f004,
25  0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a, 0x1f200, 0x1f202,
26  0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265,
27  0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393,
28  0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4,
29  0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
30  0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596,
31  0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
32  0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d8, 0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec,
33  0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a,
34  0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa8a,
35  0x1fa8e, 0x1fac6, 0x1fac8, 0x1fac8, 0x1facd, 0x1fadc, 0x1fadf, 0x1faea,
36  0x1faef, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
37]
38
hooks/vendor/bidi-js/embeddingLevels.js 691 lines
1import {
2  BN_LIKE_TYPES,
3  getBidiCharType,
4  ISOLATE_INIT_TYPES,
5  NEUTRAL_ISOLATE_TYPES,
6  STRONG_TYPES,
7  TRAILING_TYPES,
8  TYPES
9} from './charTypes.js'
10import { closingToOpeningBracket, getCanonicalBracket, openingToClosingBracket } from './brackets.js'
11
12// Local type aliases
13const {
14  L: TYPE_L,
15  R: TYPE_R,
16  EN: TYPE_EN,
17  ES: TYPE_ES,
18  ET: TYPE_ET,
19  AN: TYPE_AN,
20  CS: TYPE_CS,
21  B: TYPE_B,
22  S: TYPE_S,
23  ON: TYPE_ON,
24  BN: TYPE_BN,
25  NSM: TYPE_NSM,
26  AL: TYPE_AL,
27  LRO: TYPE_LRO,
28  RLO: TYPE_RLO,
29  LRE: TYPE_LRE,
30  RLE: TYPE_RLE,
31  PDF: TYPE_PDF,
32  LRI: TYPE_LRI,
33  RLI: TYPE_RLI,
34  FSI: TYPE_FSI,
35  PDI: TYPE_PDI
36} = TYPES
37
38/**
39 * @typedef {object} GetEmbeddingLevelsResult
40 * @property {{start, end, level}[]} paragraphs
41 * @property {Uint8Array} levels
42 */
43
44/**
45 * This function applies the Bidirectional Algorithm to a string, returning the resolved embedding levels
46 * in a single Uint8Array plus a list of objects holding each paragraph's start and end indices and resolved
47 * base embedding level.
48 *
49 * @param {string} string - The input string
50 * @param {"ltr"|"rtl"|"auto"} [baseDirection] - Use "ltr" or "rtl" to force a base paragraph direction,
51 *        otherwise a direction will be chosen automatically from each paragraph's contents.
52 * @return {GetEmbeddingLevelsResult}
53 */
54export function getEmbeddingLevels (string, baseDirection) {
55  const MAX_DEPTH = 125
56
57  // Start by mapping all characters to their unicode type, as a bitmask integer
58  const charTypes = new Uint32Array(string.length)
59  for (let i = 0; i < string.length; i++) {
60    charTypes[i] = getBidiCharType(string[i])
61  }
62
63  const charTypeCounts = new Map() //will be cleared at start of each paragraph
64  function changeCharType(i, type) {
65    const oldType = charTypes[i]
66    charTypes[i] = type
67    charTypeCounts.set(oldType, charTypeCounts.get(oldType) - 1)
68    if (oldType & NEUTRAL_ISOLATE_TYPES) {
69      charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) - 1)
70    }
71    charTypeCounts.set(type, (charTypeCounts.get(type) || 0) + 1)
72    if (type & NEUTRAL_ISOLATE_TYPES) {
73      charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1)
74    }
75  }
76
77  const embedLevels = new Uint8Array(string.length)
78  const isolationPairs = new Map() //init->pdi and pdi->init
79
80  // === 3.3.1 The Paragraph Level ===
81  // 3.3.1 P1: Split the text into paragraphs
82  const paragraphs = [] // [{start, end, level}, ...]
83  let paragraph = null
84  for (let i = 0; i < string.length; i++) {
85    if (!paragraph) {
86      paragraphs.push(paragraph = {
87        start: i,
88        end: string.length - 1,
89        // 3.3.1 P2-P3: Determine the paragraph level
90        level: baseDirection === 'rtl' ? 1 : baseDirection === 'ltr' ? 0 : determineAutoEmbedLevel(i, false)
91      })
92    }
93    if (charTypes[i] & TYPE_B) {
94      paragraph.end = i
95      paragraph = null
96    }
97  }
98
99  const FORMATTING_TYPES = TYPE_RLE | TYPE_LRE | TYPE_RLO | TYPE_LRO | ISOLATE_INIT_TYPES | TYPE_PDI | TYPE_PDF | TYPE_B
100  const nextEven = n => n + ((n & 1) ? 1 : 2)
101  const nextOdd = n => n + ((n & 1) ? 2 : 1)
102
103  // Everything from here on will operate per paragraph.
104  for (let paraIdx = 0; paraIdx < paragraphs.length; paraIdx++) {
105    paragraph = paragraphs[paraIdx]
106    const statusStack = [{
107      _level: paragraph.level,
108      _override: 0, //0=neutral, 1=L, 2=R
109      _isolate: 0 //bool
110    }]
111    let stackTop
112    let overflowIsolateCount = 0
113    let overflowEmbeddingCount = 0
114    let validIsolateCount = 0
115    charTypeCounts.clear()
116
117    // === 3.3.2 Explicit Levels and Directions ===
118    for (let i = paragraph.start; i <= paragraph.end; i++) {
119      let charType = charTypes[i]
120      stackTop = statusStack[statusStack.length - 1]
121
122      // Set initial counts
123      charTypeCounts.set(charType, (charTypeCounts.get(charType) || 0) + 1)
124      if (charType & NEUTRAL_ISOLATE_TYPES) {
125        charTypeCounts.set(NEUTRAL_ISOLATE_TYPES, (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES) || 0) + 1)
126      }
127
128      // Explicit Embeddings: 3.3.2 X2 - X3
129      if (charType & FORMATTING_TYPES) { //prefilter all formatters
130        if (charType & (TYPE_RLE | TYPE_LRE)) {
131          embedLevels[i] = stackTop._level // 5.2
132          const level = (charType === TYPE_RLE ? nextOdd : nextEven)(stackTop._level)
133          if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
134            statusStack.push({
135              _level: level,
136              _override: 0,
137              _isolate: 0
138            })
139          } else if (!overflowIsolateCount) {
140            overflowEmbeddingCount++
141          }
142        }
143
144        // Explicit Overrides: 3.3.2 X4 - X5
145        else if (charType & (TYPE_RLO | TYPE_LRO)) {
146          embedLevels[i] = stackTop._level // 5.2
147          const level = (charType === TYPE_RLO ? nextOdd : nextEven)(stackTop._level)
148          if (level <= MAX_DEPTH && !overflowIsolateCount && !overflowEmbeddingCount) {
149            statusStack.push({
150              _level: level,
151              _override: (charType & TYPE_RLO) ? TYPE_R : TYPE_L,
152              _isolate: 0
153            })
154          } else if (!overflowIsolateCount) {
155            overflowEmbeddingCount++
156          }
157        }
158
159        // Isolates: 3.3.2 X5a - X5c
160        else if (charType & ISOLATE_INIT_TYPES) {
161          // X5c - FSI becomes either RLI or LRI
162          if (charType & TYPE_FSI) {
163            charType = determineAutoEmbedLevel(i + 1, true) === 1 ? TYPE_RLI : TYPE_LRI
164          }
165
166          embedLevels[i] = stackTop._level
167          if (stackTop._override) {
168            changeCharType(i, stackTop._override)
169          }
170          const level = (charType === TYPE_RLI ? nextOdd : nextEven)(stackTop._level)
171          if (level <= MAX_DEPTH && overflowIsolateCount === 0 && overflowEmbeddingCount === 0) {
172            validIsolateCount++
173            statusStack.push({
174              _level: level,
175              _override: 0,
176              _isolate: 1,
177              _isolInitIndex: i
178            })
179          } else {
180            overflowIsolateCount++
181          }
182        }
183
184        // Terminating Isolates: 3.3.2 X6a
185        else if (charType & TYPE_PDI) {
186          if (overflowIsolateCount > 0) {
187            overflowIsolateCount--
188          } else if (validIsolateCount > 0) {
189            overflowEmbeddingCount = 0
190            while (!statusStack[statusStack.length - 1]._isolate) {
191              statusStack.pop()
192            }
193            // Add to isolation pairs bidirectional mapping:
194            const isolInitIndex = statusStack[statusStack.length - 1]._isolInitIndex
195            if (isolInitIndex != null) {
196              isolationPairs.set(isolInitIndex, i)
197              isolationPairs.set(i, isolInitIndex)
198            }
199            statusStack.pop()
200            validIsolateCount--
201          }
202          stackTop = statusStack[statusStack.length - 1]
203          embedLevels[i] = stackTop._level
204          if (stackTop._override) {
205            changeCharType(i, stackTop._override)
206          }
207        }
208
209
210        // Terminating Embeddings and Overrides: 3.3.2 X7
211        else if (charType & TYPE_PDF) {
212          if (overflowIsolateCount === 0) {
213            if (overflowEmbeddingCount > 0) {
214              overflowEmbeddingCount--
215            } else if (!stackTop._isolate && statusStack.length > 1) {
216              statusStack.pop()
217              stackTop = statusStack[statusStack.length - 1]
218            }
219          }
220          embedLevels[i] = stackTop._level // 5.2
221        }
222
223        // End of Paragraph: 3.3.2 X8
224        else if (charType & TYPE_B) {
225          embedLevels[i] = paragraph.level
226        }
227      }
228
229      // Non-formatting characters: 3.3.2 X6
230      else {
231        embedLevels[i] = stackTop._level
232        // NOTE: This exclusion of BN seems to go against what section 5.2 says, but is required for test passage
233        if (stackTop._override && charType !== TYPE_BN) {
234          changeCharType(i, stackTop._override)
235        }
236      }
237    }
238
239    // === 3.3.3 Preparations for Implicit Processing ===
240
241    // Remove all RLE, LRE, RLO, LRO, PDF, and BN characters: 3.3.3 X9
242    // Note: Due to section 5.2, we won't remove them, but we'll use the BN_LIKE_TYPES bitset to
243    // easily ignore them all from here on out.
244
245    // 3.3.3 X10
246    // Compute the set of isolating run sequences as specified by BD13
247    const levelRuns = []
248    let currentRun = null
249    let isolationLevel = 0
250    for (let i = paragraph.start; i <= paragraph.end; i++) {
251      const charType = charTypes[i]
252      if (!(charType & BN_LIKE_TYPES)) {
253        const lvl = embedLevels[i]
254        const isIsolInit = charType & ISOLATE_INIT_TYPES
255        const isPDI = charType === TYPE_PDI
256        if (isIsolInit) {
257          isolationLevel++
258        }
259        if (currentRun && lvl === currentRun._level) {
260          currentRun._end = i
261          currentRun._endsWithIsolInit = isIsolInit
262        } else {
263          levelRuns.push(currentRun = {
264            _start: i,
265            _end: i,
266            _level: lvl,
267            _startsWithPDI: isPDI,
268            _endsWithIsolInit: isIsolInit
269          })
270        }
271        if (isPDI) {
272          isolationLevel--
273        }
274      }
275    }
276    const isolatingRunSeqs = [] // [{seqIndices: [], sosType: L|R, eosType: L|R}]
277    for (let runIdx = 0; runIdx < levelRuns.length; runIdx++) {
278      const run = levelRuns[runIdx]
279      if (!run._startsWithPDI || (run._startsWithPDI && !isolationPairs.has(run._start))) {
280        const seqRuns = [currentRun = run]
281        for (let pdiIndex; currentRun && currentRun._endsWithIsolInit && (pdiIndex = isolationPairs.get(currentRun._end)) != null;) {
282          for (let i = runIdx + 1; i < levelRuns.length; i++) {
283            if (levelRuns[i]._start === pdiIndex) {
284              seqRuns.push(currentRun = levelRuns[i])
285              break
286            }
287          }
288        }
289        // build flat list of indices across all runs:
290        const seqIndices = []
291        for (let i = 0; i < seqRuns.length; i++) {
292          const run = seqRuns[i]
293          for (let j = run._start; j <= run._end; j++) {
294            seqIndices.push(j)
295          }
296        }
297        // determine the sos/eos types:
298        let firstLevel = embedLevels[seqIndices[0]]
299        let prevLevel = paragraph.level
300        for (let i = seqIndices[0] - 1; i >= 0; i--) {
301          if (!(charTypes[i] & BN_LIKE_TYPES)) { //5.2
302            prevLevel = embedLevels[i]
303            break
304          }
305        }
306        const lastIndex = seqIndices[seqIndices.length - 1]
307        let lastLevel = embedLevels[lastIndex]
308        let nextLevel = paragraph.level
309        if (!(charTypes[lastIndex] & ISOLATE_INIT_TYPES)) {
310          for (let i = lastIndex + 1; i <= paragraph.end; i++) {
311            if (!(charTypes[i] & BN_LIKE_TYPES)) { //5.2
312              nextLevel = embedLevels[i]
313              break
314            }
315          }
316        }
317        isolatingRunSeqs.push({
318          _seqIndices: seqIndices,
319          _sosType: Math.max(prevLevel, firstLevel) % 2 ? TYPE_R : TYPE_L,
320          _eosType: Math.max(nextLevel, lastLevel) % 2 ? TYPE_R : TYPE_L
321        })
322      }
323    }
324
325    // The next steps are done per isolating run sequence
326    for (let seqIdx = 0; seqIdx < isolatingRunSeqs.length; seqIdx++) {
327      const { _seqIndices: seqIndices, _sosType: sosType, _eosType: eosType } = isolatingRunSeqs[seqIdx]
328      /**
329       * All the level runs in an isolating run sequence have the same embedding level.
330       * 
331       * DO NOT change any `embedLevels[i]` within the current scope.
332       */
333      const embedDirection = ((embedLevels[seqIndices[0]]) & 1) ? TYPE_R : TYPE_L;
334
335      // === 3.3.4 Resolving Weak Types ===
336
337      // W1 + 5.2. Search backward from each NSM to the first character in the isolating run sequence whose
338      // bidirectional type is not BN, and set the NSM to ON if it is an isolate initiator or PDI, and to its
339      // type otherwise. If the NSM is the first non-BN character, change the NSM to the type of sos.
340      if (charTypeCounts.get(TYPE_NSM)) {
341        for (let si = 0; si < seqIndices.length; si++) {
342          const i = seqIndices[si]
343          if (charTypes[i] & TYPE_NSM) {
344            let prevType = sosType
345            for (let sj = si - 1; sj >= 0; sj--) {
346              if (!(charTypes[seqIndices[sj]] & BN_LIKE_TYPES)) { //5.2 scan back to first non-BN
347                prevType = charTypes[seqIndices[sj]]
348                break
349              }
350            }
351            changeCharType(i, (prevType & (ISOLATE_INIT_TYPES | TYPE_PDI)) ? TYPE_ON : prevType)
352          }
353        }
354      }
355
356      // W2. Search backward from each instance of a European number until the first strong type (R, L, AL, or sos)
357      // is found. If an AL is found, change the type of the European number to Arabic number.
358      if (charTypeCounts.get(TYPE_EN)) {
359        for (let si = 0; si < seqIndices.length; si++) {
360          const i = seqIndices[si]
361          if (charTypes[i] & TYPE_EN) {
362            for (let sj = si - 1; sj >= -1; sj--) {
363              const prevCharType = sj === -1 ? sosType : charTypes[seqIndices[sj]]
364              if (prevCharType & STRONG_TYPES) {
365                if (prevCharType === TYPE_AL) {
366                  changeCharType(i, TYPE_AN)
367                }
368                break
369              }
370            }
371          }
372        }
373      }
374
375      // W3. Change all ALs to R
376      if (charTypeCounts.get(TYPE_AL)) {
377        for (let si = 0; si < seqIndices.length; si++) {
378          const i = seqIndices[si]
379          if (charTypes[i] & TYPE_AL) {
380            changeCharType(i, TYPE_R)
381          }
382        }
383      }
384
385      // W4. A single European separator between two European numbers changes to a European number. A single common
386      // separator between two numbers of the same type changes to that type.
387      if (charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
388        for (let si = 1; si < seqIndices.length - 1; si++) {
389          const i = seqIndices[si]
390          if (charTypes[i] & (TYPE_ES | TYPE_CS)) {
391            let prevType = 0, nextType = 0
392            for (let sj = si - 1; sj >= 0; sj--) {
393              prevType = charTypes[seqIndices[sj]]
394              if (!(prevType & BN_LIKE_TYPES)) { //5.2
395                break
396              }
397            }
398            for (let sj = si + 1; sj < seqIndices.length; sj++) {
399              nextType = charTypes[seqIndices[sj]]
400              if (!(nextType & BN_LIKE_TYPES)) { //5.2
401                break
402              }
403            }
404            if (prevType === nextType && (charTypes[i] === TYPE_ES ? prevType === TYPE_EN : (prevType & (TYPE_EN | TYPE_AN)))) {
405              changeCharType(i, prevType)
406            }
407          }
408        }
409      }
410
411      // W5. A sequence of European terminators adjacent to European numbers changes to all European numbers.
412      if (charTypeCounts.get(TYPE_EN)) {
413        for (let si = 0; si < seqIndices.length; si++) {
414          const i = seqIndices[si]
415          if (charTypes[i] & TYPE_EN) {
416            for (let sj = si - 1; sj >= 0 && (charTypes[seqIndices[sj]] & (TYPE_ET | BN_LIKE_TYPES)); sj--) {
417              changeCharType(seqIndices[sj], TYPE_EN)
418            }
419            for (si++; si < seqIndices.length && (charTypes[seqIndices[si]] & (TYPE_ET | BN_LIKE_TYPES | TYPE_EN)); si++) {
420              if (charTypes[seqIndices[si]] !== TYPE_EN) {
421                changeCharType(seqIndices[si], TYPE_EN)
422              }
423            }
424          }
425        }
426      }
427
428      // W6. Otherwise, separators and terminators change to Other Neutral.
429      if (charTypeCounts.get(TYPE_ET) || charTypeCounts.get(TYPE_ES) || charTypeCounts.get(TYPE_CS)) {
430        for (let si = 0; si < seqIndices.length; si++) {
431          const i = seqIndices[si]
432          if (charTypes[i] & (TYPE_ET | TYPE_ES | TYPE_CS)) {
433            changeCharType(i, TYPE_ON)
434            // 5.2 transform adjacent BNs too:
435            for (let sj = si - 1; sj >= 0 && (charTypes[seqIndices[sj]] & BN_LIKE_TYPES); sj--) {
436              changeCharType(seqIndices[sj], TYPE_ON)
437            }
438            for (let sj = si + 1; sj < seqIndices.length && (charTypes[seqIndices[sj]] & BN_LIKE_TYPES); sj++) {
439              changeCharType(seqIndices[sj], TYPE_ON)
440            }
441          }
442        }
443      }
444
445      // W7. Search backward from each instance of a European number until the first strong type (R, L, or sos)
446      // is found. If an L is found, then change the type of the European number to L.
447      // NOTE: implemented in single forward pass for efficiency
448      if (charTypeCounts.get(TYPE_EN)) {
449        for (let si = 0, prevStrongType = sosType; si < seqIndices.length; si++) {
450          const i = seqIndices[si]
451          const type = charTypes[i]
452          if (type & TYPE_EN) {
453            if (prevStrongType === TYPE_L) {
454              changeCharType(i, TYPE_L)
455            }
456          } else if (type & STRONG_TYPES) {
457            prevStrongType = type
458          }
459        }
460      }
461
462      // === 3.3.5 Resolving Neutral and Isolate Formatting Types ===
463
464      if (charTypeCounts.get(NEUTRAL_ISOLATE_TYPES)) {
465        // N0. Process bracket pairs in an isolating run sequence sequentially in the logical order of the text
466        // positions of the opening paired brackets using the logic given below. Within this scope, bidirectional
467        // types EN and AN are treated as R.
468        const R_TYPES_FOR_N_STEPS = (TYPE_R | TYPE_EN | TYPE_AN)
469        const STRONG_TYPES_FOR_N_STEPS = R_TYPES_FOR_N_STEPS | TYPE_L
470
471        // * Identify the bracket pairs in the current isolating run sequence according to BD16.
472        const bracketPairs = []
473        {
474          const openerStack = []
475          for (let si = 0; si < seqIndices.length; si++) {
476            // NOTE: for any potential bracket character we also test that it still carries a NI
477            // type, as that may have been changed earlier. This doesn't seem to be explicitly
478            // called out in the spec, but is required for passage of certain tests.
479            if (charTypes[seqIndices[si]] & NEUTRAL_ISOLATE_TYPES) {
480              const char = string[seqIndices[si]]
481              let oppositeBracket
482              // Opening bracket
483              if (openingToClosingBracket(char) !== null) {
484                if (openerStack.length < 63) {
485                  openerStack.push({ char, seqIndex: si })
486                } else {
487                  break
488                }
489              }
490              // Closing bracket
491              else if ((oppositeBracket = closingToOpeningBracket(char)) !== null) {
492                for (let stackIdx = openerStack.length - 1; stackIdx >= 0; stackIdx--) {
493                  const stackChar = openerStack[stackIdx].char
494                  if (stackChar === oppositeBracket ||
495                    stackChar === closingToOpeningBracket(getCanonicalBracket(char)) ||
496                    openingToClosingBracket(getCanonicalBracket(stackChar)) === char
497                  ) {
498                    bracketPairs.push([openerStack[stackIdx].seqIndex, si])
499                    openerStack.length = stackIdx //pop the matching bracket and all following
500                    break
501                  }
502                }
503              }
504            }
505          }
506          bracketPairs.sort((a, b) => a[0] - b[0])
507        }
508        // * For each bracket-pair element in the list of pairs of text positions
509        for (let pairIdx = 0; pairIdx < bracketPairs.length; pairIdx++) {
510          const [openSeqIdx, closeSeqIdx] = bracketPairs[pairIdx]
511          // a. Inspect the bidirectional types of the characters enclosed within the bracket pair.
512          // b. If any strong type (either L or R) matching the embedding direction is found, set the type for both
513          // brackets in the pair to match the embedding direction.
514          let foundStrongType = false
515          let useStrongType = 0
516          for (let si = openSeqIdx + 1; si < closeSeqIdx; si++) {
517            const i = seqIndices[si]
518            if (charTypes[i] & STRONG_TYPES_FOR_N_STEPS) {
519              foundStrongType = true
520              const lr = (charTypes[i] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
521              if (lr === embedDirection) {
522                useStrongType = lr
523                break
524              }
525            }
526          }
527          // c. Otherwise, if there is a strong type it must be opposite the embedding direction. Therefore, test
528          // for an established context with a preceding strong type by checking backwards before the opening paired
529          // bracket until the first strong type (L, R, or sos) is found.
530          //    1. If the preceding strong type is also opposite the embedding direction, context is established, so
531          //    set the type for both brackets in the pair to that direction.
532          //    2. Otherwise set the type for both brackets in the pair to the embedding direction.
533          if (foundStrongType && !useStrongType) {
534            useStrongType = sosType
535            for (let si = openSeqIdx - 1; si >= 0; si--) {
536              const i = seqIndices[si]
537              if (charTypes[i] & STRONG_TYPES_FOR_N_STEPS) {
538                const lr = (charTypes[i] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
539                if (lr !== embedDirection) {
540                  useStrongType = lr
541                } else {
542                  useStrongType = embedDirection
543                }
544                break
545              }
546            }
547          }
548          if (useStrongType) {
549            charTypes[seqIndices[openSeqIdx]] = charTypes[seqIndices[closeSeqIdx]] = useStrongType
550            // * Any number of characters that had original bidirectional character type NSM prior to the application
551            // of W1 that immediately follow a paired bracket which changed to L or R under N0 should change to match
552            // the type of their preceding bracket.
553            if (useStrongType !== embedDirection) {
554              for (let si = openSeqIdx + 1; si < seqIndices.length; si++) {
555                if (!(charTypes[seqIndices[si]] & BN_LIKE_TYPES)) {
556                  if (getBidiCharType(string[seqIndices[si]]) & TYPE_NSM) {
557                    charTypes[seqIndices[si]] = useStrongType
558                  }
559                  break
560                }
561              }
562            }
563            if (useStrongType !== embedDirection) {
564              for (let si = closeSeqIdx + 1; si < seqIndices.length; si++) {
565                if (!(charTypes[seqIndices[si]] & BN_LIKE_TYPES)) {
566                  if (getBidiCharType(string[seqIndices[si]]) & TYPE_NSM) {
567                    charTypes[seqIndices[si]] = useStrongType
568                  }
569                  break
570                }
571              }
572            }
573          }
574        }
575
576        // N1. A sequence of NIs takes the direction of the surrounding strong text if the text on both sides has the
577        // same direction.
578        // N2. Any remaining NIs take the embedding direction.
579        for (let si = 0; si < seqIndices.length; si++) {
580          if (charTypes[seqIndices[si]] & NEUTRAL_ISOLATE_TYPES) {
581            let niRunStart = si, niRunEnd = si
582            let prevType = sosType //si === 0 ? sosType : (charTypes[seqIndices[si - 1]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
583            for (let si2 = si - 1; si2 >= 0; si2--) {
584              if (charTypes[seqIndices[si2]] & BN_LIKE_TYPES) {
585                niRunStart = si2 //5.2 treat BNs adjacent to NIs as NIs
586              } else {
587                prevType = (charTypes[seqIndices[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
588                break
589              }
590            }
591            let nextType = eosType
592            for (let si2 = si + 1; si2 < seqIndices.length; si2++) {
593              if (charTypes[seqIndices[si2]] & (NEUTRAL_ISOLATE_TYPES | BN_LIKE_TYPES)) {
594                niRunEnd = si2
595              } else {
596                nextType = (charTypes[seqIndices[si2]] & R_TYPES_FOR_N_STEPS) ? TYPE_R : TYPE_L
597                break
598              }
599            }
600            for (let sj = niRunStart; sj <= niRunEnd; sj++) {
601              charTypes[seqIndices[sj]] = prevType === nextType ? prevType : embedDirection
602            }
603            si = niRunEnd
604          }
605        }
606      }
607    }
608
609    // === 3.3.6 Resolving Implicit Levels ===
610
611    for (let i = paragraph.start; i <= paragraph.end; i++) {
612      const level = embedLevels[i]
613      const type = charTypes[i]
614      // I2. For all characters with an odd (right-to-left) embedding level, those of type L, EN or AN go up one level.
615      if (level & 1) {
616        if (type & (TYPE_L | TYPE_EN | TYPE_AN)) {
617          embedLevels[i]++
618        }
619      }
620        // I1. For all characters with an even (left-to-right) embedding level, those of type R go up one level
621      // and those of type AN or EN go up two levels.
622      else {
623        if (type & TYPE_R) {
624          embedLevels[i]++
625        } else if (type & (TYPE_AN | TYPE_EN)) {
626          embedLevels[i] += 2
627        }
628      }
629
630      // 5.2: Resolve any LRE, RLE, LRO, RLO, PDF, or BN to the level of the preceding character if there is one,
631      // and otherwise to the base level.
632      if (type & BN_LIKE_TYPES) {
633        embedLevels[i] = i === 0 ? paragraph.level : embedLevels[i - 1]
634      }
635
636      // 3.4 L1.1-4: Reset the embedding level of segment/paragraph separators, and any sequence of whitespace or
637      // isolate formatting characters preceding them or the end of the paragraph, to the paragraph level.
638      // NOTE: this will also need to be applied to each individual line ending after line wrapping occurs.
639      if (i === paragraph.end || getBidiCharType(string[i]) & (TYPE_S | TYPE_B)) {
640        for (let j = i; j >= 0 && (getBidiCharType(string[j]) & TRAILING_TYPES); j--) {
641          embedLevels[j] = paragraph.level
642        }
643      }
644    }
645  }
646
647  // DONE! The resolved levels can then be used, after line wrapping, to flip runs of characters
648  // according to section 3.4 Reordering Resolved Levels
649  return {
650    levels: embedLevels,
651    paragraphs
652  }
653
654  function determineAutoEmbedLevel (start, isFSI) {
655    // 3.3.1 P2 - P3
656    for (let i = start; i < string.length; i++) {
657      const charType = charTypes[i]
658      if (charType & (TYPE_R | TYPE_AL)) {
659        return 1
660      }
661      if ((charType & (TYPE_B | TYPE_L)) || (isFSI && charType === TYPE_PDI)) {
662        return 0
663      }
664      if (charType & ISOLATE_INIT_TYPES) {
665        const pdi = indexOfMatchingPDI(i)
666        i = pdi === -1 ? string.length : pdi
667      }
668    }
669    return 0
670  }
671
672  function indexOfMatchingPDI (isolateStart) {
673    // 3.1.2 BD9
674    let isolationLevel = 1
675    for (let i = isolateStart + 1; i < string.length; i++) {
676      const charType = charTypes[i]
677      if (charType & TYPE_B) {
678        break
679      }
680      if (charType & TYPE_PDI) {
681        if (--isolationLevel === 0) {
682          return i
683        }
684      } else if (charType & ISOLATE_INIT_TYPES) {
685        isolationLevel++
686      }
687    }
688    return -1
689  }
690}
691
hooks/vendor/bidi-js/reordering.js 100 lines
1import { getBidiCharType, TRAILING_TYPES } from './charTypes.js'
2import { getMirroredCharacter } from './mirroring.js'
3
4/**
5 * Given a start and end denoting a single line within a string, and a set of precalculated
6 * bidi embedding levels, produce a list of segments whose ordering should be flipped, in sequence.
7 * @param {string} string - the full input string
8 * @param {GetEmbeddingLevelsResult} embeddingLevelsResult - the result object from getEmbeddingLevels
9 * @param {number} [start] - first character in a subset of the full string
10 * @param {number} [end] - last character in a subset of the full string
11 * @return {number[][]} - the list of start/end segments that should be flipped, in order.
12 */
13export function getReorderSegments(string, embeddingLevelsResult, start, end) {
14  let strLen = string.length
15  start = Math.max(0, start == null ? 0 : +start)
16  end = Math.min(strLen - 1, end == null ? strLen - 1 : +end)
17
18  const segments = []
19  embeddingLevelsResult.paragraphs.forEach(paragraph => {
20    const lineStart = Math.max(start, paragraph.start)
21    const lineEnd = Math.min(end, paragraph.end)
22    if (lineStart < lineEnd) {
23      // Local slice for mutation
24      const lineLevels = embeddingLevelsResult.levels.slice(lineStart, lineEnd + 1)
25
26      // 3.4 L1.4: Reset any sequence of whitespace characters and/or isolate formatting characters at the
27      // end of the line to the paragraph level.
28      for (let i = lineEnd; i >= lineStart && (getBidiCharType(string[i]) & TRAILING_TYPES); i--) {
29        lineLevels[i] = paragraph.level
30      }
31
32      // L2. From the highest level found in the text to the lowest odd level on each line, including intermediate levels
33      // not actually present in the text, reverse any contiguous sequence of characters that are at that level or higher.
34      let maxLevel = paragraph.level
35      let minOddLevel = Infinity
36      for (let i = 0; i < lineLevels.length; i++) {
37        const level = lineLevels[i]
38        if (level > maxLevel) maxLevel = level
39        if (level < minOddLevel) minOddLevel = level | 1
40      }
41      for (let lvl = maxLevel; lvl >= minOddLevel; lvl--) {
42        for (let i = 0; i < lineLevels.length; i++) {
43          if (lineLevels[i] >= lvl) {
44            const segStart = i
45            while (i + 1 < lineLevels.length && lineLevels[i + 1] >= lvl) {
46              i++
47            }
48            if (i > segStart) {
49              segments.push([segStart + lineStart, i + lineStart])
50            }
51          }
52        }
53      }
54    }
55  })
56  return segments
57}
58
59/**
60 * @param {string} string
61 * @param {GetEmbeddingLevelsResult} embedLevelsResult
62 * @param {number} [start]
63 * @param {number} [end]
64 * @return {string} the new string with bidi segments reordered
65 */
66export function getReorderedString(string, embedLevelsResult, start, end) {
67  const indices = getReorderedIndices(string, embedLevelsResult, start, end)
68  const chars = [...string]
69  indices.forEach((charIndex, i) => {
70    chars[i] = (
71      (embedLevelsResult.levels[charIndex] & 1) ? getMirroredCharacter(string[charIndex]) : null
72    ) || string[charIndex]
73  })
74  return chars.join('')
75}
76
77/**
78 * @param {string} string
79 * @param {GetEmbeddingLevelsResult} embedLevelsResult
80 * @param {number} [start]
81 * @param {number} [end]
82 * @return {number[]} an array with character indices in their new bidi order
83 */
84export function getReorderedIndices(string, embedLevelsResult, start, end) {
85  const segments = getReorderSegments(string, embedLevelsResult, start, end)
86  // Fill an array with indices
87  const indices = []
88  for (let i = 0; i < string.length; i++) {
89    indices[i] = i
90  }
91  // Reverse each segment in order
92  segments.forEach(([start, end]) => {
93    const slice = indices.slice(start, end + 1)
94    for (let i = slice.length; i--;) {
95      indices[end - i] = slice[i]
96    }
97  })
98  return indices
99}
100
hooks/vendor/bidi-js/charTypes.js 67 lines
1import DATA from './data/bidiCharTypes.data.js'
2
3const TYPES = {}
4const TYPES_TO_NAMES = {}
5TYPES.L = 1 //L is the default
6TYPES_TO_NAMES[1] = 'L'
7Object.keys(DATA).forEach((type, i) => {
8  TYPES[type] = 1 << (i + 1)
9  TYPES_TO_NAMES[TYPES[type]] = type
10})
11Object.freeze(TYPES)
12
13const ISOLATE_INIT_TYPES = TYPES.LRI | TYPES.RLI | TYPES.FSI
14const STRONG_TYPES = TYPES.L | TYPES.R | TYPES.AL
15const NEUTRAL_ISOLATE_TYPES = TYPES.B | TYPES.S | TYPES.WS | TYPES.ON | TYPES.FSI | TYPES.LRI | TYPES.RLI | TYPES.PDI
16const BN_LIKE_TYPES = TYPES.BN | TYPES.RLE | TYPES.LRE | TYPES.RLO | TYPES.LRO | TYPES.PDF
17const TRAILING_TYPES = TYPES.S | TYPES.WS | TYPES.B | ISOLATE_INIT_TYPES | TYPES.PDI | BN_LIKE_TYPES
18
19let map = null
20
21function parseData () {
22  if (!map) {
23    //const start = performance.now()
24    map = new Map()
25    for (let type in DATA) {
26      if (DATA.hasOwnProperty(type)) {
27        let lastCode = 0
28        DATA[type].split(',').forEach(range => {
29          let [skip, step] = range.split('+')
30          skip = parseInt(skip, 36)
31          step = step ? parseInt(step, 36) : 0
32          map.set(lastCode += skip, TYPES[type])
33          for (let i = 0; i < step; i++) {
34            map.set(++lastCode, TYPES[type])
35          }
36        })
37      }
38    }
39    //console.log(`char types parsed in ${performance.now() - start}ms`)
40  }
41}
42
43/**
44 * @param {string} char
45 * @return {number}
46 */
47function getBidiCharType (char) {
48  parseData()
49  return map.get(char.codePointAt(0)) || TYPES.L
50}
51
52function getBidiCharTypeName(char) {
53  return TYPES_TO_NAMES[getBidiCharType(char)]
54}
55
56export {
57  getBidiCharType,
58  getBidiCharTypeName,
59  TYPES,
60  TYPES_TO_NAMES,
61  ISOLATE_INIT_TYPES,
62  STRONG_TYPES,
63  NEUTRAL_ISOLATE_TYPES,
64  BN_LIKE_TYPES,
65  TRAILING_TYPES
66}
67
hooks/vendor/bidi-js/mirroring.js 49 lines
1import data from './data/bidiMirroring.data.js'
2import { parseCharacterMap } from './util/parseCharacterMap.js'
3
4let mirrorMap
5
6function parse () {
7  if (!mirrorMap) {
8    //const start = performance.now()
9    const { map, reverseMap } = parseCharacterMap(data, true)
10    // Combine both maps into one
11    reverseMap.forEach((value, key) => {
12      map.set(key, value)
13    })
14    mirrorMap = map
15    //console.log(`mirrored chars parsed in ${performance.now() - start}ms`)
16  }
17}
18
19export function getMirroredCharacter (char) {
20  parse()
21  return mirrorMap.get(char) || null
22}
23
24/**
25 * Given a string and its resolved embedding levels, build a map of indices to replacement chars
26 * for any characters in right-to-left segments that have defined mirrored characters.
27 * @param string
28 * @param embeddingLevels
29 * @param [start]
30 * @param [end]
31 * @return {Map<number, string>}
32 */
33export function getMirroredCharactersMap(string, embeddingLevels, start, end) {
34  let strLen = string.length
35  start = Math.max(0, start == null ? 0 : +start)
36  end = Math.min(strLen - 1, end == null ? strLen - 1 : +end)
37
38  const map = new Map()
39  for (let i = start; i <= end; i++) {
40    if (embeddingLevels[i] & 1) { //only odd (rtl) levels
41      const mirror = getMirroredCharacter(string[i])
42      if (mirror !== null) {
43        map.set(i, mirror)
44      }
45    }
46  }
47  return map
48}
49
hooks/vendor/bidi-js/brackets.js 31 lines
1import data from './data/bidiBrackets.data.js'
2import { parseCharacterMap } from './util/parseCharacterMap.js'
3
4let openToClose, closeToOpen, canonical
5
6function parse () {
7  if (!openToClose) {
8    //const start = performance.now()
9    let { map, reverseMap } = parseCharacterMap(data.pairs, true)
10    openToClose = map
11    closeToOpen = reverseMap
12    canonical = parseCharacterMap(data.canonical, false).map
13    //console.log(`brackets parsed in ${performance.now() - start}ms`)
14  }
15}
16
17export function openingToClosingBracket (char) {
18  parse()
19  return openToClose.get(char) || null
20}
21
22export function closingToOpeningBracket (char) {
23  parse()
24  return closeToOpen.get(char) || null
25}
26
27export function getCanonicalBracket (char) {
28  parse()
29  return canonical.get(char) || null
30}
31