ClaudeMods
☰
ZH-TW
● 0 人在線上 · 瀏覽 0 次
贊助提交作品
GitHub 儲存庫 · 發布者 aliir74

rtl-text

一個 Claude Code 外掛,透過 fribidi 對逐字稿中的波斯文、阿拉伯文與希伯來文進行字形塑形與重新排序,讓終端機以連寫、由右至左及靠右對齊的方式繪製文字。

已翻譯

關於這個 mod

rtl-text

一個讓波斯文、阿拉伯文與希伯來文能在終端機逐字稿中正常閱讀的 Claude Code 外掛。

大多數終端機沒有 UAX #9 雙向文字處理,也沒有阿拉伯文字形塑形,所以波斯文會反向出現,字母也不會連寫。這個外掛會掛接逐字稿的繪製事件,讓每一行經過 fribidi,再繪製結果:字母相連、順序由右至左,RTL 段落靠右對齊。

同一段波斯文對話在 Ghostty 中使用外掛前後的效果

Claude Code 外掛屬於搶先體驗功能,預設關閉。 只有在足夠新的 Claude Code 中設定 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 後,這個外掛才會執行任何操作。 請參閱需求。

它會塑形 Claude Code 印出的內容,但無法修正你在提示列中輸入的內容;請參閱限制。

需求

1. fribidi

brew install fribidi        # macOS
apt install fribidi         # Debian/Ubuntu

2. 足夠新、能夠攜帶 function-hooks 執行階段的 Claude Code。

claude --version

外掛屬於搶先體驗功能,不在公開變更日誌或官方文件中,因此沒有公布的「從哪個版本開始提供」可以引用。已知的是:2.1.260 是報告中最早攜帶該執行階段的建置版本,而本外掛在 2.1.271 到 2.1.273 上經過測試。如果你使用的是更早版本,而外掛沒有任何反應,請先更新,再排查其他問題。

由於這項功能仍在搶先體驗階段,外掛 API 可能在版本之間無預告地變更,因此 Claude Code 更新後可能會讓這個外掛失效,直到重新建置為止。這項警告來自 Anthropic 自己產生的型別宣告。

3. 開啟 function hooks。 即使建置版本已經包含該功能,它仍受環境變數控制。未設定時,外掛會安裝、載入,然後靜默地什麼也不做;這也是本外掛看起來無法運作的最常見原因。

持久的設定方式是 ~/.claude/settings.json,無論以何種方式啟動,都會套用到每個工作階段:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

如果你只會從終端機啟動 Claude Code,也可以在 shell 中匯出(~/.zshrc、~/.bashrc):

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

4. 覆蓋 Arabic Presentation Forms 區塊(U+FB50-U+FEFF)的等寬字型,因為這是本外掛輸出的內容。等寬很重要:如果把比例字型的波斯文硬塞進終端機的網格,一個詞的字母會被拉開;等寬字型則會讓連寫字形在儲存格邊緣相接。

Vazir Code 可以使用(「Vazir Code Hack」變體會將它與 Hack 搭配,用於拉丁字形)。請注意,該專案已停止維護,不過已發佈的字型仍然可用。它有一個重要缺口:沒有 U+FEF5-U+FEFC,也就是 8 個 lam-alef 連字形式(سلام 中的 لا)。只需從 Vazirmatn 補上這些字形;你也需要安裝該字型。在 Ghostty 中:

font-family = "JetBrains Mono"
font-family = "Vazir Code Hack"
font-codepoint-map = U+FEF5-U+FEFC=Vazirmatn

不要把 Vazirmatn 這類比例字型當成一般的 font-family 後備字型加入。它也會搶走拉丁字形,破壞英文文字。

終端機支援

外掛本身與終端機無關:它向 Claude Code 要求 terminal surface,內部沒有任何程式知道你執行的是哪一個終端機。它究竟會幫忙還是添亂,取決於終端機是否自行處理 bidi。

本外掛輸出的是已經重新排序成視覺順序、也已經轉換成展示字形的文字。這些字元仍帶有強 RTL 雙向類別,因此如果終端機自行執行 UAX #9,就會再次重新排序,讓你回到原本的問題。

| 終端機 | 使用這個外掛? | | |---|---|---| | Ghostty | 是 — 已測試 | 沒有隨附 bidi;#1442 尚未關閉 | | kitty | 預期可以 | #2109 自 2019 年起未關閉 | | Alacritty | 預期可以 | #663 自 2017 年起未關閉 | | foot | 預期可以 | #756,維護者不接受 | | Windows Terminal | 預期可以 | #538 自 2019 年起未關閉 | | VS Code terminal (xterm.js) | 預期可以 | vscode#271615 | | iTerm2 | 只有關閉自己的 RTL 才能使用 | 3.6+ 在 Settings → General → Experimental 提供實驗性的 RTL;預設關閉 | | WezTerm | 只有在 bidi_enabled = false 時使用 | 這是預設值 | | macOS Terminal.app | 否 | 透過 CoreText 使用原生 bidi;會二次反轉 | | GNOME Terminal / VTE | 否 | VTE 0.58 起支援 bidi | | Konsole | 否 | Bug 403729 已解決並修正 | | mlterm | 否 | 建置時使用 --enable-fribidi 會支援 bidi,多數套件都是如此 |

只有 Ghostty 這一列經過測試。其他內容都是從各專案自己的 issue tracker 讀取,因此應把它們當作強先驗,而不是保證。如果你試用了某個終端機,歡迎提交 PR 修正對應列。

如果你的終端機屬於下方那一組,就不需要這個外掛。它們的繪製效果已經比本外掛更好,因為它們處理的是邏輯文字,也能處理編輯器。

安裝

claude plugin marketplace add aliir74/claude-code-rtl
claude plugin install rtl-text@claude-code-rtl

也可以直接從複製的目錄執行,不必安裝:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/claude-code-rtl

安裝會在使用者範圍自行寫入 enabledPlugins 項目,因此每個專案都會啟用它。之後更新:

claude plugin update rtl-text

記住需求 3:沒有 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 時,外掛雖然會成功安裝,之後卻什麼也不做。

涵蓋範圍

| 元件 | 已塑形 | |---|---| | AssistantMessage | 是,作為 markdown | | UserMessage | 是,作為 markdown,並交還給引擎以保留背景帶 | | CommandOutput | 是,作為純文字 | | ToolResult(僅 Bash) | 是,作為純文字 |

限制

不涵蓋編輯器。 在提示列中輸入波斯文仍然會出問題,任何外掛都無法修正:輸入不是 RenderComponent,而 ui.input 只會針對繪製 hook 自己畫出的 Input 元素觸發,從來不會針對 Claude Code 自己的編輯器觸發。只有終端機取得真正的 bidi 支援才能解決它。對 Ghostty 來說,這就是 PR #14142;截至 2026 年 9 月仍未合併。

複製塑形後的文字會得到展示字形。 螢幕上有什麼,複製出來就是什麼,因此從逐字稿複製的文字會處於視覺順序,無法乾淨地貼回按邏輯順序運作的編輯器。

它會刻意放過兩類內容。程式碼區塊、行內的 `code` 片段和 URL 都會原樣傳遞,因此命令不會被重新排序。如果 Bash 結果過大並已持久化到檔案,也會交給引擎處理;否則重繪 stdout 會把截斷的檢視呈現得像完整內容。

選項

在外掛設定中設定。每個選項都是可選的,並會在程式碼中限制範圍,因此錯誤值會降級處理,而不是讓載入失敗。

| 選項 | 預設值 | 意義 | |---|---|---| | alignment | auto | auto 只會讓自身基準方向為 RTL 的段落靠右對齊;left 塑形但不加內距;right 一律靠右對齊 | | fribidiPath | fribidi | 二進位檔的路徑 | | margin | 4 | 換行前從 viewport.columns 保留的儲存格數。維持在 1 以上:這是防止 wrap: 'truncate-end' 在儲存格測量偶爾差 1 時裁掉靠右對齊那一行開頭的餘量 | | cacheSize | 256 | LRU 中保留的已塑形訊息數 | | timeoutMs | 2000 | 一次 fribidi 呼叫在該列退回引擎自行繪製前允許花費的時間 | | replyBullet | ⏺ | 回覆開頭列的標記。繪製自己的樹會取代引擎整列,包括標記,因此外掛會重新繪製它;對 RTL 而言,標記位於句子開頭所在的右邊緣;空字串會將它關閉 |

疑難排解

完全沒有變化。 外掛依設計靜默失敗:每個 hook 都會退回引擎自己的列,而不是破壞逐字稿。請依照以下清單逐項檢查。

fribidi --version                              # is the binary there?
grep FUNCTION_HOOKS ~/.claude/settings.json    # is the gate set?
claude plugin list                             # is rtl-text installed and enabled?

如果你是在 shell 中匯出變數,而不是寫入 settings.json,請用 echo $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS 檢查。settings.json 中的項目不會顯示在那裡:它是在 Claude Code 程序內設定,而不是在 shell 中設定。

如果 fribidi 安裝在不尋常的位置,請將 fribidiPath 設為絕對路徑。

逐字稿中出現 no runnable fribidi after 3 tries。 外掛會在第一次繪製時探測二進位檔,並印出每個候選項目失敗的原因。這則訊息會指出真正的原因,通常是路徑問題。

字母相連但間距很大。 問題出在字型,不在外掛。請參閱上面的需求 4:你幾乎肯定使用了比例字型。

波斯文文字反向。 你的終端機可能自帶 bidi,正在撤銷外掛的工作。檢查終端機支援表。

運作方式

text -> hasRtl? -> split blocks -> split markdown prefix -> protect code/URLs
     -> WRAP in logical order -> one fribidi call per direction -> restore -> pad -> Text rows

順序就是整個設計的核心。換行會根據視埠寬度,以邏輯順序進行,只有完成的列才會塑形。先塑形再對視覺結果換行,會把段落開頭的詞放到最後一列;harness/transform-real.check.ts 存在就是為了捕捉這個 bug。

還有兩個值得知道的決定:

  • fribidi 從不替我們換行。 它自己的 --width 換行不理解單字,會在 token 中間拆開詞,因此每次呼叫都傳入 --nobreak,由我們負責換行。
  • 內距也由我們負責。 fribidi 的 --width 內距在顯示儲存格層面精確,但無法表達每列的 auto 對齊,所以由 cellWidth 完成。這讓 cellWidth 成為承重部分,因此 harness 會把它和 fribidi 自己的內距比較,作為獨立的驗證依據。

每個基礎方向只用一個 fribidi 子程序處理整則訊息,而不是每列一個;結果按 (markdown, width, text) 快取,因為每次重繪和調整大小時,繪製 hook 都會重新執行。

開發

.claude-plugin/plugin.json   manifest and userConfig
.claude-plugin/marketplace.json
hooks/                       the mod; no npm dependencies, no Node imports
  register.ts                the four ui.render hooks and the engine adapter
  transform.ts               the pipeline
  segment.ts                 blocks, markdown prefixes, inline protection, tables
  wrap.ts  cell-width.ts  align.ts  lru-cache.ts  rtl-detect.ts  fribidi-args.ts
  render-tree.ts             lines -> the surface's Box/Text/Code constructors
tests/                       run by `claude plugin test`
harness/*.check.ts           run by tsx; the only tests that touch the real binary
.claude/types/               generated by /plugin-types, do not hand-edit
docs/research/               measured findings this mod was built from

四项檢查,因為沒有任何一項涵蓋另一項:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .     # hooks + pure logic, fribidi mocked
npx --yes tsx --test harness/*.check.ts                      # the real fribidi binary
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate .  # structure: hooks, matchers, $ calls
npx --yes -p t

安裝

請先查看作者 README,確認 marketplace 與外掛名稱;指令可能隨儲存庫結構而變動。

claude plugin marketplace add aliir74/claude-code-rtl
claude plugin install rtl-text
原文 / README

rtl-text

A Claude Code mod that makes Persian, Arabic and Hebrew readable in the terminal transcript.

Most terminals have no UAX #9 bidi and no Arabic shaping, so Persian arrives reversed and with its letters unjoined. This mod hooks the transcript's render events, runs each line through fribidi, and draws the result: letters joined, order right-to-left, RTL paragraphs flush right.

The same Persian exchange in Ghostty, before and after the mod

Claude Code mods are early access and off by default. This one does nothing at all until you set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 on a recent enough Claude Code. See Requirements.

It shapes what Claude Code prints. It cannot fix what you type into the prompt box; see Limits.

Requirements

1. fribidi

brew install fribidi        # macOS
apt install fribidi         # Debian/Ubuntu

2. A Claude Code new enough to carry the function-hooks runtime.

claude --version

Mods are an early-access feature. They are not in the public changelog and not in the official docs, so there is no published "available from" version to point at. What is known: 2.1.260 is the earliest build reported to carry the runtime, and this mod is tested on 2.1.271 through 2.1.273. If you are on something older and the mod does nothing, update before debugging anything else.

Because the feature is early access, the plugin API can change between releases without notice, so a Claude Code update may break this mod until it is rebuilt. That warning is Anthropic's own, from the generated type declarations.

3. Function hooks switched on. The feature is gated behind an environment variable even on a build that has it. Without it the plugin installs, loads and silently does nothing, which is the single most common reason this mod appears not to work.

The durable way is ~/.claude/settings.json, which applies to every session however you start it:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Or export it in your shell (~/.zshrc, ~/.bashrc) if you only ever launch Claude Code from a terminal:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

4. A monospace font covering the Arabic Presentation Forms block (U+FB50-U+FEFF), which is what this mod emits. Monospace matters: a proportional Persian face forced into a terminal's cell grid pulls the letters of a word apart, where a monospace face has its joined forms drawn to meet at the cell edges.

Vazir Code works (the "Vazir Code Hack" variant pairs it with Hack for the Latin glyphs). Note the project is discontinued, though the released fonts are fine. It has one gap that matters: no U+FEF5-U+FEFC, the eight lam-alef ligature forms (the لا in سلام). Fill just those from Vazirmatn, which you will need installed as well. In Ghostty:

font-family = "JetBrains Mono"
font-family = "Vazir Code Hack"
font-codepoint-map = U+FEF5-U+FEFC=Vazirmatn

Do not add a proportional face like Vazirmatn as a plain font-family fallback. It wins the Latin glyphs too and spoils your English text.

Terminal support

The mod itself is terminal-agnostic: it asks Claude Code for the terminal surface and nothing in it knows which terminal you run. What decides whether it helps or hurts is whether your terminal does its own bidi.

This mod emits text already reordered into visual order and already converted to presentation forms. Those characters still carry a strong RTL bidi class, so a terminal that runs its own UAX #9 pass will reorder them a second time and put you back where you started.

| Terminal | Use this mod? | | |---|---|---| | Ghostty | Yes — tested | No bidi shipped; #1442 open | | kitty | Expected yes | #2109 open since 2019 | | Alacritty | Expected yes | #663 open since 2017 | | foot | Expected yes | #756, declined by the maintainer | | Windows Terminal | Expected yes | #538 open since 2019 | | VS Code terminal (xterm.js) | Expected yes | vscode#271615 | | iTerm2 | Only with its own RTL off | 3.6+ has experimental RTL under Settings → General → Experimental; off by default | | WezTerm | Only with bidi_enabled = false | That is the default | | macOS Terminal.app | No | Native bidi via CoreText; would double-reverse | | GNOME Terminal / VTE | No | Bidi since VTE 0.58 | | Konsole | No | Bug 403729 resolved fixed | | mlterm | No | Bidi when built --enable-fribidi, as most packages are |

Only the Ghostty row is tested. Everything else is read off each project's own issue tracker, so treat it as a strong prior rather than a promise. If you try one, a PR correcting the row is welcome.

If your terminal is in the bottom group, you do not need this mod. Its rendering is already better than what this mod can offer, since it works on logical text and can handle the composer too.

Install

claude plugin marketplace add aliir74/claude-code-rtl
claude plugin install rtl-text@claude-code-rtl

Or run it straight from a clone, without installing:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/claude-code-rtl

Installing writes the enabledPlugins entry itself, at user scope, so it is on in every project. Later updates:

claude plugin update rtl-text

Remember requirement 3: without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 the plugin installs successfully and then does nothing.

What it covers

| Component | Shaped | |---|---| | AssistantMessage | yes, as markdown | | UserMessage | yes, as markdown, handed back to the engine so it keeps its background band | | CommandOutput | yes, as plain text | | ToolResult (Bash only) | yes, as plain text |

Limits

The composer is not covered. Typing Persian into the prompt box is still broken, and no mod can fix it: the input is not a RenderComponent, and ui.input fires only for Input elements a render hook itself drew, never for Claude Code's own composer. Only your terminal gaining real bidi fixes that. For Ghostty that is PR #14142, not merged as of September 2026.

Copying shaped text gives you presentation forms. What is on screen is what gets yanked, so text copied out of the transcript is in visual order and will not paste cleanly back into a logical-order editor.

Two things it deliberately leaves alone. A code block, an inline `code` span and a URL are passed through untouched, so nothing reorders your commands. A Bash result whose output was too large and got persisted to a file is left to the engine, because redrawing stdout would present a truncated view as if it were the whole thing.

Options

Set them in the plugin's config. Every one is optional and clamped in code, so a bad value degrades rather than failing the load.

| Option | Default | Meaning | |---|---|---| | alignment | auto | auto right-aligns only paragraphs whose own base direction is RTL; left shapes without padding; right always right-aligns | | fribidiPath | fribidi | Path to the binary | | margin | 4 | Cells held back from viewport.columns before wrapping. Keep it at 1 or more: it is the slack that stops wrap: 'truncate-end' clipping the start of a right-aligned line if the cell measure is ever off by one | | cacheSize | 256 | Shaped messages kept in the LRU | | timeoutMs | 2000 | How long a fribidi call may take before the row falls back to the engine's own drawing | | replyBullet | ⏺ | Marker on the opening line of a reply. Drawing our own tree replaces the engine's whole row, marker included, so the mod redraws it, on the right edge for RTL where the sentence starts; an empty string leaves it off |

Troubleshooting

Nothing changes at all. The mod is failing silently by design: every hook falls back to the engine's own row rather than breaking your transcript. Work down this list.

fribidi --version                              # is the binary there?
grep FUNCTION_HOOKS ~/.claude/settings.json    # is the gate set?
claude plugin list                             # is rtl-text installed and enabled?

If you exported the variable in your shell instead of putting it in settings.json, check it with echo $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS. A settings.json entry will not show up there: it is set inside the Claude Code process, not in your shell.

If fribidi is installed somewhere unusual, set fribidiPath to its absolute path.

A no runnable fribidi after 3 tries line in the transcript. The mod probes for the binary on the first render and prints why each candidate failed. The message names the real reason, which is usually a path problem.

Letters are joined but gappy. That is the font, not the mod. See requirement 4 above: you are almost certainly rendering with a proportional face.

Persian text is reversed. Your terminal probably has its own bidi, and it is undoing the mod's work. Check the terminal support table.

How it works

text -> hasRtl? -> split blocks -> split markdown prefix -> protect code/URLs
     -> WRAP in logical order -> one fribidi call per direction -> restore -> pad -> Text rows

The ordering is the whole design. Wrapping happens in logical order against the viewport width, and only the finished lines are shaped. Shaping first and wrapping the visual result puts the paragraph's opening words on the last line, which is the bug harness/transform-real.check.ts exists to catch.

Two other decisions worth knowing:

  • fribidi never breaks lines for us. Its own --width breaking is not word-aware and splits words mid-token, so every call passes --nobreak and the wrapping is ours.
  • Padding is ours too. fribidi's --width padding is display-cell accurate, but it cannot express a per-line auto alignment, so cellWidth does it. That makes cellWidth load-bearing, which is why the harness checks it against fribidi's own padding as an independent oracle.

One fribidi subprocess handles a whole message per base direction, never one per line, and the result is cached by (markdown, width, text) because render hooks re-run on every redraw and resize.

Development

.claude-plugin/plugin.json   manifest and userConfig
.claude-plugin/marketplace.json
hooks/                       the mod; no npm dependencies, no Node imports
  register.ts                the four ui.render hooks and the engine adapter
  transform.ts               the pipeline
  segment.ts                 blocks, markdown prefixes, inline protection, tables
  wrap.ts  cell-width.ts  align.ts  lru-cache.ts  rtl-detect.ts  fribidi-args.ts
  render-tree.ts             lines -> the surface's Box/Text/Code constructors
tests/                       run by `claude plugin test`
harness/*.check.ts           run by tsx; the only tests that touch the real binary
.claude/types/               generated by /plugin-types, do not hand-edit
docs/research/               measured findings this mod was built from

Four checks, because none covers another:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .     # hooks + pure logic, fribidi mocked
npx --yes tsx --test harness/*.check.ts                      # the real fribidi binary
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate .  # structure: hooks, matchers, $ calls
npx --yes -p typescript@latest tsc -p tsconfig.json           # types

The hooks environment has no fs, network or process, so the real-binary tests cannot live under claude plugin test. They are named *.check.ts rather than *.test.ts because that runner globs the whole plugin directory and would refuse a module importing node:child_process.

Notes for anyone editing this

  • Elements are never object literals. They come from $.ui.resolve(e). A { type: 'Box' } literal typechecks fine and is then refused at runtime, and the engine quietly redraws its own row, which looks exactly like the mod not being installed.
  • $ may only be passed to a function declared at the top of the file. claude plugin validate enforces this so it can report which $ calls a plugin makes. That is why the helpers in register.ts are top-level functions taking a Context rather than closures.
  • No raw NUL or private-use characters in source. Use String.fromCharCode. A literal escape can land as a real control byte, which turns the file binary and makes grep silently miss it.
  • userConfig entries need a title. The manifest schema rejects them otherwise.
  • Regenerate the type contract after a Claude Code update: claude -p '/plugin-types'.
  • Bump the version in BOTH plugin.json and the marketplace.json entry, in sync. The plugin cache is keyed on that string, so an unbumped release does not reach anyone who has already installed it, and a marketplace entry version overrides the manifest's if they differ.

Status

Working, confirmed in Ghostty 1.3.2 on Claude Code 2.1.273. Persian renders joined, right-to-left and flush right, Latin runs inside a Persian sentence keep their own direction, and code blocks pass through.

License

MIT

更多類似作品