Toggle proper RTL layout for mixed Hebrew/English chat text

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.
| Mod | What it does |
|---|---|
| clawd-hud | A pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter |
| task-hud | A to-do progress bar, and a pane with a pixel creature for each sub-agent |
| followups | Claude flags what it notices, asks whether to track it, and keeps a to-do you can export |
| rtl-toggle | Fixes the reading order of lines that mix English and Hebrew |
| goal-run | Asks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done |
| brain-hud | Agent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph |
| factory | /factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR |
Add the marketplace once, then install the ones you want:
/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod
The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.
If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.
Above the prompt (the AbovePrompt band)
| Mood | When | Scenes |
|---|---|---|
idle | waiting for you | drifting with a firefly, juggling, watering a sprout |
working | a turn is running | at the terminal, stacking blocks, pondering, sending out sub-agents |
working > 90s | a hard task | a smoke break on a crate joins the rotation |
loop | the same tool with the same input 3× in one turn | riding a coaster loop it can't get out of |
done | the turn answered | fireworks and a hop (a lightbulb if the task was hard) |
relax | 4 min after done | stargazing on a hill, a pool float, cocoa by a snowy window |
error | API error or refusal | goes gray under a little storm cloud, then idle |
| Personal (Pro / Max) | Enterprise | |
|---|---|---|
| Batteries | 5h window, 7d weekly | 7d weekly, mo monthly spend cap ("over by …" past 100%) |
| Cost | not shown | $ spent this session |
| "resets in" | the API's reset time | the API's reset time; for the spend cap without one, the 1st of next month |
Cache timer (auto) | 1 hour | 5 minutes |
Beside the model picker (the SessionMode slot)
ctx 34% · 68k/200k.In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.
The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.
/plugin install clawd-hud@claudemod
Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:
| Option | Values | Default | What it does |
|---|---|---|---|
plan | auto, personal, enterprise | auto | Which limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit). |
cacheTtl | auto, 5m, 1h | auto | The cache timer's length. auto is 1h on personal plans and 5m on enterprise. |
hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).
0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.
0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.
0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.
0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".
0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>
/plugin install task-hud@claudemod
TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt./task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.
Tests: claude plugin test plugins/task-hud.

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>
While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.
/plugin install followups@claudemod
flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.autoDetect option (claude plugin configure followups@claudemod).Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.a), Skip (s) and Add all, so it isn't lost.○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.| Button | Writes to | Why |
|---|---|---|
| Memory | CLAUDE.md | Claude reads it at the start of every session |
| Docs | docs/FOLLOW-UPS.md | a page of its own |
| README | README.md, under Known issues & follow-ups | visible to everyone |
The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.
Tests: claude plugin test plugins/followups.
| Toggle off | Toggle on |
|---|---|
![]() | ![]() |
A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.
/plugin install rtl-toggle@claudemod
עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.
Tests: claude plugin test plugins/rtl-toggle.

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>
/plugin install goal-run@claudemod

plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added./session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.askOnStart (on / off) turns the start-of-session question off./factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.Tests: claude plugin test plugins/goal-run.

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>
brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.
/plugin install brain-hud@claudemod
Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.
/brain-hud:mistake-brain whooks/register.tsx 77 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { toRtl } from './rtl'
5
6// Session-scoped toggle state (NOT a module-level variable, which would be
7// lost on hot reload). Declared in ../types/index.d.ts under 'rtl-toggle'.
8const rtlEnabled = atom({ plugin: 'rtl-toggle', key: 'enabled' } as const, false)
9
10// The live claude-code.d.ts exposes no layout-level direction prop on Box,
11// Text or Markdown, so affected lines get a leading RLM instead (see rtl.ts).
12
13const statusText = (enabled: boolean) => (enabled ? 'RTL: on (עברית: פעיל)' : 'RTL: off (עברית: כבוי)')
14
15export const register: Register = on => {
16 // `/rtl` works on every surface, including those that draw no band above the
17 // prompt (VS Code, mobile): `/rtl` toggles, `/rtl on|off` sets, `/rtl status` reports.
18 on('session.start', async ($, e, next) => {
19 await $.command.register({
20 name: 'rtl',
21 description: 'Toggle RTL layout for mixed Hebrew/Arabic and English messages',
22 argumentHint: '[on|off|status]',
23 immediate: true,
24 })
25 return next(e)
26 })
27
28 on('command.run', { command: 'rtl' }, async ($, e) => {
29 const arg = e.args.trim().toLowerCase()
30 if (arg === 'status') return { text: statusText(await read($, rtlEnabled)) }
31 if (arg && arg !== 'on' && arg !== 'off' && arg !== 'toggle') {
32 return { text: `Unknown argument "${arg}". Usage: /rtl [on|off|status]` }
33 }
34 await update($, rtlEnabled, v => (arg === 'on' ? true : arg === 'off' ? false : !v))
35 return { text: statusText(await read($, rtlEnabled)) }
36 })
37
38 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
39 // Compose with whatever sits beneath (surveys, other mods) instead of
40 // replacing it: a hook that answers without `next` suppresses them.
41 const below = await next(e)
42 if (e.props.hasSurvey) return below
43
44 const enabled = await read($, rtlEnabled)
45 const { Box, Button } = $.ui.resolve(e)
46
47 return (
48 <Box flexDirection="column">
49 {below}
50 <Box justifyContent="flex-end">
51 <Button
52 key="rtl-toggle"
53 label={enabled ? 'עברית: פעיל' : 'עברית: כבוי'}
54 variant="primary"
55 onPress={() => update($, rtlEnabled, v => !v)}
56 />
57 </Box>
58 </Box>
59 )
60 })
61
62 // One hook per message site, each with a literal matcher so `e` narrows to that site's props.
63 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
64 if (!(await read($, rtlEnabled))) return next(e)
65 return next({ ...e, props: { ...e.props, text: rtlText(e.props.text, e.surface) } })
66 })
67 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
68 if (!(await read($, rtlEnabled))) return next(e)
69 return next({ ...e, props: { ...e.props, text: rtlText(e.props.text, e.surface) } })
70 })
71}
72
73// Table rows are fixed cell by cell except on the terminal, which has no bidi layout to fix.
74function rtlText(text: string, surface: string): string {
75 return toRtl(text, { tableCells: surface !== 'terminal' })
76}
77hooks/rtl.ts 233 lines1// Pure text helpers for the rtl-toggle mod (no engine access, unit-testable).
2
3// RIGHT-TO-LEFT MARK: a zero-width strong RTL character. Placed first in a
4// block's content it becomes the first strong char, so renderers using
5// automatic direction (dir=auto / UBA rule P2) give the block an RTL base
6// direction and right alignment. Isolates (RLI...PDI) are not used because
7// P2 skips isolate contents, leaving such blocks LTR-aligned.
8export const RLM = ''
9// Legacy controls: no longer emitted, still recognised by the double-mark
10// guard so text marked by an older version is not marked again.
11const LEGACY_RLI = ''
12const LEGACY_RLE = ''
13
14// Dominance is counted in WORDS (maximal runs of RTL letters or of LTR
15// letters), not letters. A line is marked when it has at least one RTL word and
16// rtlWords / (rtlWords + ltrWords) >= MIN_RTL_WORD_SHARE, i.e. RTL words are
17// at least as many as LTR words: a tie goes to RTL.
18export const MIN_RTL_WORD_SHARE = 0.5
19
20// Every block Unicode assigns to right-to-left scripts (the ranges UAX #9
21// defaults to R/AL): Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic
22// and the Arabic supplements/extensions (U+0590-08FF), Hebrew and Arabic
23// presentation forms (U+FB1D-FDFF, U+FE70-FEFF), and the historic and modern
24// RTL scripts of planes 1 (U+10800-10FFF: Hanifi Rohingya, Yezidi, ...) and
25// U+1E800-1EFFF (Adlam, Arabic mathematical). Explicit ranges are ~4x faster
26// than \p{Script=...} lookups. Any other letter (Latin, Cyrillic, Greek, CJK,
27// ...) is LTR, so a Russian word counts against the RTL share like an English one.
28const RTL_RANGES = String.raw`\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}`
29const RTL_CHAR = new RegExp(`[${RTL_RANGES}]`, 'u')
30const RTL_WORDS_G = new RegExp(`[${RTL_RANGES}]+`, 'gu')
31// LTR word: a letter outside the RTL blocks (`[^\P{L}<rtl>]`), then more such
32// letters or combining marks (Devanagari vowel signs, decomposed accents).
33const LTR_LETTER = String.raw`[^\P{L}${RTL_RANGES}]`
34const LTR_WORDS_G = new RegExp(String.raw`${LTR_LETTER}(?:${LTR_LETTER}|\p{M})*`, 'gu')
35const FIRST_LETTER = /\p{L}/u
36
37// Leading markdown prefix that must stay at column 0 of the rendered line:
38// indentation, blockquote markers, then a list marker (with optional task
39// checkbox) or an ATX heading marker. Each whitespace run belongs to exactly
40// one quantifier, so a failed match cannot backtrack quadratically.
41const QUOTES = String.raw`[ \t]*(?:>[ \t]*)*`
42const LIST_MARKER = String.raw`(?:[-*+]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?`
43const PREFIX_RE = new RegExp(String.raw`^${QUOTES}(?:${LIST_MARKER}|#{1,6}[ \t]+)?`)
44const LIST_ITEM_RE = new RegExp(String.raw`^${QUOTES}${LIST_MARKER}`)
45// Link syntax removed before counting words (never from the output text):
46// `](target)` of links/images (one level of parentheses allowed in the target),
47// `][ref]` labels, <scheme://...> autolinks and bare http(s)/www URLs. Every
48// pattern stops at the next opener, so none can rescan a line quadratically.
49const LINK_NOISE_RES = [
50 /\]\((?:[^()\n]|\([^()\n]*\))*\)/g,
51 /\]\[[^\]]*\]/g,
52 /<[A-Za-z][A-Za-z0-9+.-]*:\/\/[^<>\s]*>/g,
53 /https?:\/\/\S+/gi,
54 /www\.\S+/gi,
55]
56const INDENTED_CODE_RE = /^(?: {4}|\t)/
57// Fences are recognised inside blockquotes and list items too (`> ```ts`).
58const FENCE_RE = /^[ \t]*(?:>[ \t]*)*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?(`{3,}|~{3,})(.*)$/
59
60// Replace inline code spans (`x`, ``x ` y``) with a space: identifiers and
61// commands inside them are not prose and must not count as LTR words. A span
62// closes at the next backtick run of the same length (CommonMark); an opener
63// with no closer is literal. Linear: closers are found via a precomputed table.
64function stripCodeSpans(s: string): string {
65 if (!s.includes('`')) return s
66 const starts: number[] = []
67 const lens: number[] = []
68 for (let i = 0; i < s.length; ) {
69 if (s[i] !== '`') { i++; continue }
70 let j = i
71 while (s[j] === '`') j++
72 starts.push(i)
73 lens.push(j - i)
74 i = j
75 }
76 // closer[k]: index of the next run with the same length as run k, or -1.
77 const closer = new Array<number>(lens.length)
78 const seen = new Map<number, number>()
79 for (let k = lens.length - 1; k >= 0; k--) {
80 closer[k] = seen.get(lens[k]!) ?? -1
81 seen.set(lens[k]!, k)
82 }
83 let out = ''
84 let pos = 0
85 for (let k = 0; k < lens.length; k++) {
86 const c = closer[k]!
87 if (c < 0) continue
88 out += s.slice(pos, starts[k]) + ' '
89 pos = starts[c]! + lens[c]!
90 k = c
91 }
92 return out + s.slice(pos)
93}
94
95export type RtlOptions = {
96 // Mark table cells individually (see wrapTableRow). Off for the terminal,
97 // which has no bidi layout to fix and measures column widths itself.
98 tableCells?: boolean
99}
100
101// Whether a line or cell body (markdown prefix already removed) should get a
102// leading RLM: it starts with an LTR letter and RTL words are at least as many
103// as LTR words.
104function needsMark(rest: string): boolean {
105 if (rest.startsWith(RLM) || rest.startsWith(LEGACY_RLI) || rest.startsWith(LEGACY_RLE)) return false // already marked
106
107 const first = FIRST_LETTER.exec(rest)
108 if (!first || RTL_CHAR.test(first[0])) return false // empty, or already RTL-leading
109
110 const countable = LINK_NOISE_RES.reduce((t, re) => t.replace(re, ' '), stripCodeSpans(rest))
111 const rtl = (countable.match(RTL_WORDS_G) ?? []).length
112 const ltr = (countable.match(LTR_WORDS_G) ?? []).length
113 return rtl >= 1 && rtl / (rtl + ltr) >= MIN_RTL_WORD_SHARE
114}
115
116// Mark each qualifying cell of a `|`-led table row right after its leading
117// whitespace, so renderers that pick each cell's direction from its first
118// strong char right-align it. Cells split on unescaped `|` (GFM: `\|` inside
119// a cell, code spans included, is a literal pipe).
120function wrapTableRow(line: string): string {
121 let out = ''
122 let cell = -1 // start of the current cell's text, -1 before the first pipe
123 const flush = (end: number) => {
124 const body = line.slice(cell, end)
125 const lead = body.length - body.trimStart().length
126 out += needsMark(body.slice(lead)) ? body.slice(0, lead) + RLM + body.slice(lead) : body
127 }
128 for (let i = 0; i < line.length; i++) {
129 if (line[i] === '\\') { i++; continue }
130 if (line[i] !== '|') continue
131 if (cell < 0) out += line.slice(0, i + 1)
132 else { flush(i); out += '|' }
133 cell = i + 1
134 }
135 flush(line.length) // a row without a trailing pipe ends in a cell; whitespace is never marked
136 return out
137}
138
139// Mark one line (no newline, no trailing \r) when RTL words are at least as many as
140// LTR words but it starts with an LTR letter. Markdown prefixes stay in front of the RLM.
141export function wrapLineIfNeeded(line: string, options: RtlOptions = {}): string {
142 if (!RTL_CHAR.test(line)) return line // no RTL content at all
143 if (line.trimStart().startsWith('|')) return options.tableCells ? wrapTableRow(line) : line
144 if (INDENTED_CODE_RE.test(line) && !LIST_ITEM_RE.test(line)) return line // indented code block
145
146 const prefix = PREFIX_RE.exec(line)![0]
147 const rest = line.slice(prefix.length)
148 return needsMark(rest) ? `${prefix}${RLM}${rest}` : line
149}
150
151type Fence = { char: string; length: number } | null
152
153// Rewrite a run of lines starting in fence state `fence`; returns the new state.
154function convertLines(lines: string[], fence: Fence, options: RtlOptions): { out: string[]; fence: Fence } {
155 const out = lines.map(raw => {
156 const hasCr = raw.endsWith('\r')
157 const line = hasCr ? raw.slice(0, -1) : raw
158 const m = FENCE_RE.exec(line)
159 const marker = m?.[1] ?? ''
160 const info = m?.[2] ?? ''
161
162 if (fence) {
163 if (m && marker[0] === fence.char && marker.length >= fence.length && info.trim() === '') fence = null
164 return raw
165 }
166 if (m && !(marker[0] === '`' && info.includes('`'))) {
167 fence = { char: marker[0]!, length: marker.length }
168 return raw
169 }
170
171 return wrapLineIfNeeded(line, options) + (hasCr ? '\r' : '')
172 })
173 return { out, fence }
174}
175
176// Messages are redrawn far more often than they change: every streamed chunk
177// redraws the growing answer, and scrolling or toggling redraws old ones.
178// Finished results are kept in a small LRU bounded by total size, and the
179// last converted text's complete lines are reused when the next text extends
180// it, so a streaming answer only converts its newest lines. One memo per set
181// of options, since they change the result.
182const CACHE_BUDGET = 4_000_000 // chars, across all cached results of one memo
183
184type Memo = {
185 cache: Map<string, string>
186 size: number
187 stream: { src: string; out: string; fence: Fence }
188}
189const memos = new Map<boolean, Memo>()
190
191function remember(memo: Memo, text: string, result: string) {
192 if (text.length > CACHE_BUDGET) return
193 memo.cache.set(text, result)
194 memo.size += text.length
195 for (const [k] of memo.cache) {
196 if (memo.size <= CACHE_BUDGET) break
197 memo.cache.delete(k)
198 memo.size -= k.length
199 }
200}
201
202// Rewrite a whole message: skips fenced code blocks (and their fence lines)
203// and keeps CRLF endings intact.
204export function toRtl(text: string, options: RtlOptions = {}): string {
205 if (!RTL_CHAR.test(text)) return text
206
207 const key = !!options.tableCells
208 let memo = memos.get(key)
209 if (!memo) memos.set(key, (memo = { cache: new Map(), size: 0, stream: { src: '', out: '', fence: null } }))
210
211 const hit = memo.cache.get(text)
212 if (hit !== undefined) {
213 memo.cache.delete(text) // refresh LRU position
214 memo.cache.set(text, hit)
215 return hit
216 }
217
218 const base = memo.stream.src && text.startsWith(memo.stream.src) ? memo.stream : { src: '', out: '', fence: null }
219 const cut = text.lastIndexOf('\n') + 1 // end of the last complete line
220 let head = base.out
221 let fence = base.fence
222 if (cut > base.src.length) {
223 const done = convertLines(text.slice(base.src.length, cut - 1).split('\n'), fence, options)
224 head += done.out.join('\n') + '\n'
225 fence = done.fence
226 memo.stream = { src: text.slice(0, cut), out: head, fence }
227 }
228 const result = head + convertLines([text.slice(cut)], fence, options).out[0]
229
230 remember(memo, text, result)
231 return result
232}
233types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'rtl-toggle': { enabled: boolean }
4 }
5}
6