Bidirectional (RTL/LTR) plugin for opencode — fixes mixed Persian/Arabic/Hebrew + English text in the opencode terminal, Desktop and web UI
Languages: 🇮🇷 فارسی · 🇬🇧 English · 🇸🇦 العربية
👤 Author: Sina Khanzadeh · ID: programmer_1998 (Discord / Telegram / Instagram) · Email: khanzadeh.1377@gmail.com
When an English word or identifier appears inside a Persian/Arabic sentence — like SINA, a filename, a path or a package name — the opencode UI scrambles the word order because it does not implement the Unicode Bidirectional Algorithm (UAX #9):
You typed (logical order): سلام من SINA هستم
Broken rendering: هستم SINA سلام من
Correct rendering (visual): هستم SINA من سلام ← read right-to-left = «سلام من SINA هستم»
This affects the terminal/TUI, the Desktop app and the web UI, both while typing the prompt (composer) and when displaying messages / model replies.
This project solves the problem in two complementary layers:
| Layer | Tool | What it fixes |
|---|---|---|
| 1) Text layer (opencode server) | server plugin |
Sent user messages, chat history, model replies and tool output — using Unicode bidi isolates (RLI / LRI / PDI) per UAX #9. From opencode 1.18.31 the Terminal/Desktop renderer also applies dir="auto" + unicode-bidi: plaintext natively, so this layer focuses on the TUI/web and wording that the model keeps LTR tokens intact |
| 2) UI layer (Desktop) | app.asar patch (engine patch/lib/patch-asar.mjs + per-OS wrapper), which the server plugin also applies & self-heals |
Every text surface of the UI: the live prompt composer, chat messages, tab/session & terminal titles, menus, dialogs, toasts, settings, file tree, review filenames, attachments, permissions and questions — by injecting dir="auto" + unicode-bidi: plaintext into app.asar (code, diffs and shell output stay LTR) — plus the Vazirmatn font for all UI text |
🔍 How does it work?
- The plugin hooks into opencode's server hooks (
chat.message,experimental.text.complete,tool.execute.after, …). For each paragraph it resolves the direction via the first-strong character rule; RTL paragraphs are wrapped inRLI…PDI, and the English/code tokens inside them (likeSINA,/path/to/file) are isolated withLRI…PDIso their order never flips. These Unicode control characters can upset naive cell-by-cell terminal renderers and pollute the buffer fed to the model (e.g.^noise), so this wrapping is opt-in viainlineControls: true. By default the plugin injects no control characters and additionally strips any direction-control chars already stuck to the text. - The Desktop patch appends a
<style>and<script>toout/renderer/index.htmlinsideapp.asar. Since opencode 1.18.31 already fixes messages/replies natively, the patch targets only what the app still misses: the live composer ([data-component="prompt-input"],[data-component="prompt-input-v2"]) and the window/tab titles ([data-slot="tab-title"],[data-slot="terminal-tab-title"]), forcingdir="auto"+ plaintext bidi on them. The same patch also inlines the Vazirmatn font as a base64@font-faceand sets--font-family-sans, so the whole UI uses Vazirmatn with no system font install and no network. - No plugin hook reaches the Desktop renderer: the server plugin only has server hooks, the CLI plugin is terminal-only, and opencode's "GUI extensions" system is development-builds-only in the released app. So patching
app.asaris the only route — and the server plugin applies and self-heals that patch automatically (see below).
- ✅ Correct rendering of mixed Persian/Arabic/Hebrew/Urdu + English paragraphs
- ✅ Isolation of English words and identifiers inside RTL text (
SINA,API, paths) - ✅ Full protection of code — fenced blocks, indented code and
inline codeare left untouched - ✅ Previous messages (chat history) are corrected when loaded
- ✅ Automatic direction detection (first-strong character) + optional forced
rtl/ltr - ✅ Optional system-guidance so the model answers in your language and keeps code/paths LTR
- ✅ Optional
OPENCODE_RTL*env vars for tools - ✅ TUI status commands:
RTL: Show StatusandRTL: Analyze Sample(also on v2 via the new CLI API) - ✅ Desktop patch for all three OSes (Linux / macOS / Windows) with automatic backup,
app.asar.unpackedlayout verification and--unpatch - ✅ Full desktop UI coverage: chat & composer, session and terminal tabs, menus, dialogs, toasts, settings, file tree & file viewer, review panel, attachments, permissions and questions — with LTR protection for code/diffs/shell output
- ✅ Vazirmatn (OFL) font embedded in the desktop UI (no system font install needed)
- ✅ Self-healing: the server plugin applies the desktop patch automatically and re-applies it after every desktop update (no manual re-run)
- ✅ Compatible with opencode 1.19.0 and 2.x (
./v2server entry,./v2/tuiCLI entry)
The prompt composer, while you are still typing, is rendered by opencode's own UI and no plugin hook has access to the live buffer. So:
- Desktop app → this part is fixed by the
app.asarpatch (enginepatch/lib/patch-asar.mjsplus a per-OS wrapper). No plugin/extension reaches the desktop renderer, so the server plugin applies this patch itself and self-heals it — setdesktop: falseif you'd rather patch manually. - Terminal/TUI → it is rendered cell-by-cell by
@opentui/core; the real fix there belongs to opencode itself. Yet as soon as you send the message, your prompt and the model reply are fixed by layer 1 everywhere.
opencode loads plugins only at startup. After any config change, close opencode and launch it again.
git clone git@github.com:programmer-1998/RTL_OpenCode.git
cd RTL_OpenCode
npm ci && npm run buildThen point the config at the folder (or at dist/server.js):
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"/home/<user>/RTL_OpenCode",
{ "language": "auto", "isolateToolOutput": "off" }
]
]
}💡 Instead of an absolute path you can use
./RTL_OpenCode(relative to the config dir) orfile:///...— all three forms are valid.
Put the plugin in one of these project-local folders and opencode will find it with no config:
.opencode/plugin/rtl/package.json
.opencode/plugin/rtl/dist/…
In v2 the plugin key was renamed to plugins, and the TUI plugin is configured separately in cli.json:
// opencode.jsonc
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{ "package": "opencode-rtl-fix", "options": { "language": "auto" } }
]
}// cli.json (for the RTL terminal commands)
{
"plugins": ["opencode-rtl-fix"]
}This package ships both APIs: a ./v2 server entry (v2 ctx.session.hook) and a ./v2/tui CLI entry. Hook mapping: chat.message → prompt, system.transform → context/compaction/generate/title, tool.execute.after → tool.hook("execute.after"), shell.env → shell.hook("create.before").
💡 Layer 1 (the server plugin, written in JavaScript) behaves exactly the same on Linux, macOS and Windows. Only the Desktop patch (layer 2) has a separate file per OS — see below.
| Option | Default | Description |
|---|---|---|
enabled |
true |
Master switch |
language |
"auto" |
auto, none, or fa / ar / he / ur (force a language) |
systemGuidance |
true |
Append RTL guidance to the system prompt (or a custom string) |
isolateUserMessages |
"auto" |
off / auto / always — isolate user messages |
isolateAssistantText |
"auto" |
Isolate model replies |
isolateToolOutput |
"off" |
Isolate tool output (keep off if exact copy/paste matters) |
inlineControls |
false |
Never inject invisible Unicode control chars (RLI/LRI/PDI) into text. In the default mode the plugin also strips direction-control chars already stuck to the text (e.g. LRM/RLM pasted from the web) so the terminal buffer and the model context stay clean — this prevents ^ rendering noise. Set true for manual isolate wrapping |
minRtlRatio |
0.2 |
Minimum RTL character ratio for auto-detection |
minRtlCharacters |
2 |
Minimum RTL character count |
digitMode |
"preserve" |
Digit conversion: preserve / latin / arabic-indic / eastern-arabic |
forceDirection |
"auto" |
Force isolate direction: auto / rtl / ltr |
alignRtlParagraphs |
false |
Visual right-align in terminal via padding (align also changes raw text; TUI only) |
rtlWrapColumn |
96 |
Wrap column used by alignment |
rtlAlignColumn |
96 |
Right-align column |
directionEnv |
true |
Export OPENCODE_RTL* in tool environments |
desktop |
true |
Auto-install / self-heal the Desktop patch (app.asar + Vazirmatn). Set false to leave app files untouched |
debug |
false |
Log via client.app.log() |
Direction note: direction follows the first strong character — a sentence starting with Persian becomes RTL, one starting with English/code becomes LTR. To force everything RTL:
"forceDirection": "rtl".
If you see extra characters such as ^^^^^ while RTL text renders in the TUI, the cause is invisible Unicode characters — bidi isolates (U+2066/U+2067/U+2069) or LRM/RLM stuck to text pasted from the web — that a naive cell-by-cell renderer miscalculates. What this plugin does:
- Since this version, control characters are not injected by default and any direction-control chars already present are stripped from the flowing text (
inlineControls: false). - If you manually set
inlineControls: trueand see noise, turn it back off. - Old sessions created with previous plugin versions may still carry these characters in stored text; start a new session.
The Electron app renders parts of its text without any automatic direction. The patch appends a <style> + <script> to out/renderer/index.html inside app.asar so every text surface — chat & composer, tabs, menus, dialogs, toasts, settings, file tree/viewer, review panel, attachments, permissions and questions — behaves exactly like in modern browsers (code, diffs and shell output intentionally stay LTR). The same patch inlines the Vazirmatn font as base64 and sets --font-family-sans, so the whole UI uses Vazirmatn with no system font install and no network.
🔁 Important: from 0.2.0 the server plugin applies this patch and self-heals it — after every desktop update (which replaces
app.asar) the plugin re-applies it automatically. The manual scripts below are only needed if you setdesktop: false, or want it applied right now without a restart.
The patch is powered by a shared JavaScript engine (patch/lib/patch-asar.mjs) that runs identically on all three OSes; only the per-OS wrapper locates the app and takes the needed privileges (sudo / UAC):
| OS | Install file | Requirements |
|---|---|---|
| 🐧 Linux | patch/linux/desktop-rtl.sh |
node 20+ and sudo |
| 🍎 macOS | patch/macos/desktop-rtl.sh |
node 20+ and sudo |
| 🪟 Windows | patch/windows/desktop-rtl.ps1 |
node 20+ |
sudo bash patch/linux/desktop-rtl.shsudo bash patch/macos/desktop-rtl.sh💡 If the app refuses to open after patching (broken code signature), re-sign it:
sudo codesign --force --deep --sign - "/Applications/OpenCode.app"
Open PowerShell and run:
powershell -ExecutionPolicy Bypass -File patch\windows\desktop-rtl.ps1Confirm the UAC prompt. (Or simply double-click patch\windows\desktop-rtl.cmd.)
- Backs up
app.asarin place (app.asar.bak-rtl); - Extracts, patches
index.html(bidi rules + the Vazirmatn@font-face), repacks the archive; - Verifies the
app.asar.unpackedlayout (native modules likenode-pty) one-by-one against the previous version so nothing breaks; - Writes a version stamp (
app.asar.rtl-stamp) that the plugin uses to tell whether the patch is current and to upgrade older patches (never patching an already-patched file twice).
Then close and reopen the opencode Desktop app.
# Linux / macOS
sudo bash patch/linux/desktop-rtl.sh --unpatch
sudo bash patch/macos/desktop-rtl.sh --unpatch# Windows
powershell -ExecutionPolicy Bypass -File patch\windows\desktop-rtl.ps1 -UnpatchThe app auto-updates and the update replaces the patched file. From 0.2.0 no manual step is needed:
- the server plugin (started with the Desktop/CLI) checks, on load, whether the patch exists and is current;
- the
app.asaris locked while the app runs, so the plugin first probes whether the file is writable and only patches when the app is closed, retrying every 2 minutes meanwhile; - so: install the plugin → quit the Desktop app → next launch it is patched. After every update it heals itself the same way.
Prefer to keep the plugin away from app files? Set
"desktop": falseand use the manual scripts. None of this needssudo/UAC unless the app is installed in a system location.
Default paths: Linux
/opt/OpenCode/resources/app.asar, macOS/Applications/OpenCode.app/Contents/Resources/app.asar, Windows%LOCALAPPDATA%\Programs\OpenCode\resources\app.asar. If installed elsewhere: Linux/macOSsudo DESKTOP_ASAR=/path/to/app.asar bash patch/<linux|macos>/desktop-rtl.sh, Windowspowershell ... -Asar C:\path\to\app.asar.
-
Close and reopen opencode (so the plugin loads).
-
In Desktop, type this in the composer — it should read naturally right-to-left with
SINAin the right place:سلام من SINA هستم -
Send a mixed prompt containing a path/code, e.g.:
محتوای فایل src/config.ts را بخوان و لیست بدهand check that
src/config.tsand the Persian sentence are not reordered. -
In the terminal, if you have the command, check plugin status:
/rtl.status -
Run the automated tests:
cd RTL_OpenCode && npm test
npm ci
npm run build # tsc → dist/
npm run typecheck
npm test # build + tests (incl. the desktop-patch engine)RTL_OpenCode/
├── src/
│ ├── core.ts ← bidi engine: direction detection, isolates, markdown/code, alignment
│ ├── desktop.ts ← desktop self-heal guard (applies / re-applies app.asar)
│ ├── server.ts ← server hooks, v1 (1.19)
│ ├── tui.ts ← TUI commands in v1
│ └── index.ts ← exports
├── plugin-v2.ts ← v2 server plugin (opencode 2, `./v2`)
├── tui-v2.ts ← v2 CLI plugin (RTL commands, `./v2/tui`)
│ └── index.ts ← exports
├── test/core.test.js ← tests
├── patch/
│ ├── lib/patch-asar.mjs ← shared cross-platform patch engine + self-heal guard
│ ├── fonts/Vazirmatn-Variable.woff2 ← Vazirmatn (OFL), inlined as base64
│ ├── linux/desktop-rtl.sh ← Desktop patch for Linux
│ ├── macos/desktop-rtl.sh ← Desktop patch for macOS
│ └── windows/
│ ├── desktop-rtl.ps1 ← Desktop patch for Windows
│ └── desktop-rtl.cmd ← Windows double-click launcher
├── examples/opencode.json ← full example config
└── dist/ ← build output (committed)
-
.github/workflows/ci.yml— on push/PR: install, typecheck, build, test on Node 20/22/24 + assertdistis in sync with source + syntax and engine tests for all three OS patch scripts. -
.github/workflows/release.yml— on av*tag push:- creates a GitHub Release with a bundle (zip) +
npm pack(.tgz); - if the
NPM_TOKENsecret is configured, it also publishes to npm (opencode-rtl-fix).
Tag a version:
git tag v0.1.0 && git push origin v0.1.0 - creates a GitHub Release with a bundle (zip) +
This project was built by Sina Khanzadeh.
- 🌐 Personal site: sina-khanzadeh.ir
- 📧 Email: khanzadeh.1377@gmail.com
- 🆔 Same ID across social media:
programmer_1998(Discord · Telegram · Instagram) - 📦 GitHub: programmer-1998/RTL_OpenCode
- opencode — github.com/anomalyco/opencode · opencode.ai
- Contributor: sadegh19b — embedded Vazirmatn font, desktop-patch self-heal and the
desktopoption (PR #2) - Vazirmatn font — by Saber Rastikerdar, licensed under SIL OFL 1.1
- The opencode-rtl npm plugin (author:
razavioo) that inspired this implementation - Related items in the opencode monorepo:
- issue #35319 — broken mixed text on Desktop
- issue #32984 — RTL rendering in the UIs
- issue #40286 — TUI RTL/bidi
- PR #25455 —
dir="auto"for web
MIT © Sina Khanzadeh — sina-khanzadeh.ir · khanzadeh.1377@gmail.com
