ClaudeMods
☰
ZH-CN
● 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

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

更多类似作品