Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## v0.5.1

- **Per-block direction detection (Auto mode)** — response paragraphs previously picked their direction from the *first* character (`unicode-bidi: plaintext`), so a Hebrew answer with a line starting in English drifted left, list bullets flipped to the wrong side, and mixed Hebrew/English lines were hard to read. Auto mode now decides direction per block element (paragraph, list item, heading, blockquote) by *presence* of Hebrew/Arabic/Persian characters: mixed lines align right and read correctly with bullets on the right, while pure-English headings and lines stay naturally left-aligned. Applied live during streaming; code blocks, tool output, and thinking blocks are untouched.

## v0.5.0

- **New: Force LTR (Always) mode** (`Claude RTL: Force LTR (Always)`) — pins the whole chat (messages, input box, question/permission dialogs, Plan Preview) to left-to-right, even when the conversation contains Hebrew, Arabic, or Persian text. The direction choice is now symmetric: users who want RTL pick an RTL mode, users who prefer a stable LTR layout while chatting in an RTL language pick LTR Always. Shown in the status bar as `LTR: Always` and available from the status-bar menu; survives Claude Code updates via auto-reactivate like the other modes. (Contributed by @moeseif in #19.)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Auto Mode: Per-Block Direction Detection

**Date:** 2026-08-04
**Status:** Approved
**Scope:** `src/content.ts` only (Auto mode CSS + JS)

## Problem

In Auto mode, once a bubble is tagged `.YBYrtl`, response paragraphs get
`unicode-bidi: plaintext`, which sets each paragraph's base direction from its
**first strong character**. In a Hebrew conversation this causes:

1. Paragraphs that start with an English word flip to LTR and align left.
2. List markers (bullets/dots) follow that direction and land on the wrong side.
3. Mixed Hebrew/English lines scramble punctuation and word order, hard to read.

## Decision

Direction is decided **per block element** by *presence* of RTL characters, not
by first character:

- Block **contains** Hebrew/Arabic/Persian → `dir="rtl"` → right-aligned,
bullet on right, embedded English isolated inline.
- Block has **no** RTL characters (pure English heading, free-standing code-ish
line) → `dir="ltr"` → left-aligned, natural.

User-approved trade-off: a lone English word on its own line goes left; a Hebrew
paragraph containing lots of English still goes right.

## Mechanism

### JS (extends the existing Auto-mode observer in `RTL_AUTO_JS_CODE`)

- For each `.YBYrtl` bubble, walk block elements — `p`, `li`, `h1`–`h6`,
`blockquote` — **only inside markdown containers** (`[class*="root_"]`);
tool/thinking/todo UI reuses the same tags and must not be tagged.
- Additionally skip anything inside every container the LTR overrides protect:
`pre`, `code`, `codeBlockWrapper_`, `thinking_`/`thinkingContent_`,
`toolUse_`/`toolSummary_`/`toolBody_`/`toolResult_`/`toolReference_`,
`todoList_`/`todoListContainer_` — a native `dir` attribute on a child is not
neutralized by direction rules on its container.
- Set `dir="rtl"` or `dir="ltr"` per the RTL-char test
(`/[֐-׿؀-ۿݐ-ݿﭐ-﷿ﹰ-﻾]/`).
- Re-scan on mutations, debounced — same pattern as the BiDi stripper — so a
streamed line that starts in English flips right once Hebrew arrives.

### CSS (in `AUTO_RTL_RULES`)

- Remove `unicode-bidi: plaintext` and the blanket `text-align: right` from
response-paragraph rules.
- Add:
- `[dir="rtl"]` blocks → `direction: rtl; text-align: right; unicode-bidi: isolate`.
- `[dir="ltr"]` blocks → `direction: ltr; text-align: left`.
- Bubble-level `direction: rtl` layout rules stay (bubble alignment unchanged).
- Prompt input rules stay as-is (first-char live detection is correct while
typing).

## Out of Scope

- Active / Always / LTR-Always modes (their semantics are explicit, not
detected).
- Plan Preview.
- Input field.

## Verification

- `npm run build` clean; `npm test` (concurrency) still passes.
- Manual: in Auto mode with a mixed Hebrew/English response confirm —
English-first mixed line right-aligned and readable, bullets on the right,
pure-English heading stays left, code blocks untouched, streaming reply
settles correctly.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "rtl-support",
"displayName": "RTL Support",
"description": "Adds RTL (Right-to-Left) text support for Hebrew, Arabic and Persian to Claude Code in VS Code, Cursor, Antigravity and Kiro",
"version": "0.5.0",
"version": "0.5.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the lockfile version synchronized

Bumping the extension to 0.5.1 here leaves both version fields in package-lock.json at 0.5.0. This makes the committed package metadata inconsistent and causes tooling that reads the lockfile to report the previous release; regenerate or update the lockfile so its top-level and packages[""] versions are also 0.5.1.

Useful? React with 👍 / 👎.

"publisher": "Digitizers",
"license": "MIT",
"homepage": "https://github.com/Digitizers/vs-code-rtl-extension#readme",
Expand Down Expand Up @@ -117,7 +117,7 @@
"build": "node esbuild.mjs --production",
"watch": "node esbuild.mjs --watch",
"pretest": "tsc -p tsconfig.test.json",
"test": "node test/concurrency.test.cjs",
"test": "node test/concurrency.test.cjs && node test/dir-walker-scope.test.cjs",
"package": "npx @vscode/vsce package"
},
"devDependencies": {
Expand Down
143 changes: 139 additions & 4 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,17 +294,27 @@ const AUTO_RTL_RULES = `
unicode-bidi: plaintext;
}

/* Claude's markdown responses (excluding thinking block) */
/* Claude's markdown responses (excluding thinking block) — container stays RTL
for bubble layout; per-block direction is set by the Auto-mode block walker
via dir attributes (contains-RTL → rtl, pure LTR → ltr) */
.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) {
direction: rtl;
unicode-bidi: plaintext;
}

.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(p, ul, ol, h1, h2, h3, h4, blockquote),
.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) > :is(ul, ol) li {
.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="rtl"] {
direction: rtl;
text-align: right;
unicode-bidi: isolate;
Comment on lines +304 to +307

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore inline bidi isolation for links

When an RTL-tagged paragraph contains an English link or URL surrounded by Hebrew/Arabic text, isolating only the entire block does not isolate the anchor because unicode-bidi is not inherited. This change also removes the previous anchor-specific unicode-bidi: plaintext rule, so URL punctuation and adjacent text can be reordered by the surrounding RTL bidi context. Preserve an inline isolation rule for anchors while using the new per-block direction rules.

Useful? React with 👍 / 👎.

}

.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) :is(p, li, h1, h2, h3, h4, h5, h6, blockquote)[dir="ltr"] {
direction: ltr;
text-align: left;
unicode-bidi: isolate;
}

/* Links keep their own bidi run — unicode-bidi is not inherited, so block-level
isolation alone leaves URL punctuation reorderable by the RTL context */
.YBYrtl [class*="root_"]:not([class*="thinkingContent_"] [class*="root_"]) a {
unicode-bidi: plaintext;
}
Expand Down Expand Up @@ -906,6 +916,131 @@ export const RTL_AUTO_JS_CODE = `
}, 50);
}).observe(scanRoot, { childList: true, subtree: true, characterData: true });
})();

/* Per-Block Direction — sets dir="rtl"/"ltr" on markdown blocks inside .YBYrtl
bubbles by presence of RTL characters, so mixed lines read right-aligned
while pure-English blocks stay natural LTR. CSS keys off the dir attribute. */
(function() {
var RTL = /[\\u0590-\\u05FF\\u0600-\\u06FF\\u0750-\\u077F\\uFB50-\\uFDFF\\uFE70-\\uFEFE]/;
var BLOCK_SEL = 'p,li,h1,h2,h3,h4,h5,h6,blockquote';
/* Every container the LTR overrides protect — a native dir attribute on a
child is NOT neutralized by direction rules on the container, so the
walker must never tag inside these */
var SKIP_SEL = '[class*="codeBlockWrapper_"],pre,code,[class*="thinkingContent_"],[class*="thinking_"],[class*="toolUse_"],[class*="toolSummary_"],[class*="toolBody_"],[class*="toolResult_"],[class*="toolReference_"],[class*="todoList_"],[class*="todoListContainer_"]';

/* Blocks that render independently (own marker / own alignment) — text
inside them must not influence an ancestor block's direction. p and
headings inside a loose <li><p>…</p></li> DO count toward the li,
because the list marker belongs to the li. */
var INDEPENDENT_SEL = 'li,blockquote';

function ownsText(el, parent) {
var node = parent;
while (node && node !== el) {
if (node.matches && node.matches(INDEPENDENT_SEL)) return false;
node = node.parentElement;
}
return true;
}

/* True when the block has RTL text of its OWN — outside skipped containers
(a code block quoting Hebrew inside an English list item must not flip
it) and not owned by an independently nested block (a Hebrew sub-item
must not flip its English parent; the sub-item gets its own dir) */
function hasOwnRtl(el) {
if (!RTL.test(el.textContent || '')) return false; /* fast path */
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
var n;
while ((n = walker.nextNode())) {
if (!RTL.test(n.nodeValue)) continue;
var p = n.parentElement;
if (!p) continue;
if (p.closest && p.closest(SKIP_SEL)) continue;
if (!ownsText(el, p)) continue;
return true;
}
return false;
}

function tagBlocks(bubble) {
/* Only markdown containers hold prose; tool/thinking/todo UI reuses the
same tags and must keep its LTR layout untouched */
var roots = bubble.querySelectorAll('[class*="root_"]');
for (var r = 0; r < roots.length; r++) {
var rootEl = roots[r];
if (rootEl.closest && rootEl.closest(SKIP_SEL)) continue;
var els = rootEl.querySelectorAll(BLOCK_SEL);
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.closest && el.closest(SKIP_SEL)) continue;
var want = hasOwnRtl(el) ? 'rtl' : 'ltr';
if (el.getAttribute('dir') !== want) el.setAttribute('dir', want);
}
}
}

var dirRoot = document.getElementById('root');
if (!dirRoot) return;

function scanAll() {
var bubbles = dirRoot.querySelectorAll('.YBYrtl');
for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]);
}

scanAll();

/* Debounced watcher — re-tags during streaming and when bubbles gain
.YBYrtl. Only bubbles touched by the mutation batch are re-tagged.
Mutations outside any bubble are ignored (never a full scan — unrelated
LTR streaming must not re-walk the whole chat history); new bubbles are
caught directly from added nodes and class changes. */
var dirTimer = null;
var pendingBubbles = [];

function addBubble(bubble) {
if (pendingBubbles.indexOf(bubble) === -1) pendingBubbles.push(bubble);
}

function noteAncestorBubble(node) {
var el = node.nodeType === 1 ? node : node.parentElement;
var bubble = el && el.closest ? el.closest('.YBYrtl') : null;
if (bubble) addBubble(bubble);
}

function noteRecord(rec) {
if (rec.type === 'attributes') {
/* class change: the target itself may have just become a bubble */
var t = rec.target;
if (t.nodeType === 1 && t.classList && t.classList.contains('YBYrtl')) { addBubble(t); return; }
noteAncestorBubble(t);
return;
}
if (rec.type === 'childList') {
/* added nodes may BE or CONTAIN bubbles not yet under one */
for (var a = 0; a < rec.addedNodes.length; a++) {
var node = rec.addedNodes[a];
if (node.nodeType !== 1) continue;
if (node.classList && node.classList.contains('YBYrtl')) addBubble(node);
else if (node.querySelectorAll) {
var inner = node.querySelectorAll('.YBYrtl');
for (var b = 0; b < inner.length; b++) addBubble(inner[b]);
}
}
}
noteAncestorBubble(rec.target);
}

new MutationObserver(function(records) {
for (var i = 0; i < records.length; i++) noteRecord(records[i]);
if (dirTimer || !pendingBubbles.length) return;
dirTimer = setTimeout(function() {
dirTimer = null;
var bubbles = pendingBubbles;
pendingBubbles = [];
for (var i = 0; i < bubbles.length; i++) tagBlocks(bubbles[i]);
}, 100);
}).observe(dirRoot, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
})();
${PERMISSION_RTL_JS}
/* End RTL Toggle Button */
`;
92 changes: 92 additions & 0 deletions test/dir-walker-scope.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Regression guard for the Auto-mode per-block direction walker
* (Codex round-1 P2, PR #2): the walker must only tag blocks inside
* markdown containers, and its skip selector must cover every container
* the LTR overrides protect — a native dir attribute on a child is not
* neutralized by direction rules on its container.
*/
const fs = require('fs');
const path = require('path');

const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'content.ts'), 'utf8');

const walker = src.match(/\/\* Per-Block Direction[\s\S]*?\n\}\)\(\);/);
if (!walker) {
console.error('FAIL — per-block direction walker not found in content.ts');
process.exit(1);
}
const w = walker[0];

const failures = [];

// 1. Walker must query blocks from markdown roots, never bubble-wide.
if (!/querySelectorAll\('\[class\*="root_"\]'\)/.test(w)) {
failures.push('walker no longer scopes its block query under [class*="root_"] markdown containers');
}

// 2. Skip selector must include every protected container class.
const skipSel = (w.match(/var SKIP_SEL = '([^']+)'/) || [])[1] || '';
for (const cls of [
'codeBlockWrapper_', 'thinkingContent_', 'thinking_',
'toolUse_', 'toolSummary_', 'toolBody_', 'toolResult_', 'toolReference_',
'todoList_', 'todoListContainer_',
]) {
if (!skipSel.includes(cls)) failures.push(`SKIP_SEL is missing protected container "${cls}"`);
}
for (const tag of ['pre', 'code']) {
if (!new RegExp(`(^|,)${tag}(,|$)`).test(skipSel)) failures.push(`SKIP_SEL is missing "${tag}"`);
}

// 3. CSS dir rules must keep the thinking-block guard (Copilot round-2, PR #2):
// per-block [dir] styling must never apply under thinkingContent_.
for (const dir of ['rtl', 'ltr']) {
const re = new RegExp(
`\\[class\\*="root_"\\]:not\\(\\[class\\*="thinkingContent_"\\] \\[class\\*="root_"\\]\\) :is\\([^)]*\\)\\[dir="${dir}"\\]`
);
if (!re.test(src)) {
failures.push(`CSS [dir="${dir}"] block rule lost its thinkingContent_ guard`);
}
}

// 4. Direction detection must ignore text inside skipped containers
// (Codex round-4 P2, PR #2): a code block quoting Hebrew inside an
// English list item must not flip the item to RTL.
if (!/function hasOwnRtl/.test(w) || !/createTreeWalker/.test(w)) {
failures.push('walker direction detection no longer excludes skipped-container text (hasOwnRtl/TreeWalker missing)');
}
if (!/hasOwnRtl\(el\)/.test(w)) {
failures.push('tagBlocks no longer uses skip-aware hasOwnRtl for direction detection');
}

// 5. Independently nested blocks' RTL text must not flip the parent, while
// loose-list paragraphs still count toward their li (Codex round-5/6 P2s):
// ownership stops at nested li/blockquote, not at p/headings.
if (!/INDEPENDENT_SEL = 'li,blockquote'/.test(w) || !/function ownsText/.test(w)) {
failures.push('hasOwnRtl lost the independent-nested-block ownership rule (ownsText/INDEPENDENT_SEL)');
}
if (!/ownsText\(el, p\)/.test(w)) {
failures.push('hasOwnRtl no longer consults ownsText for direction detection');
}

// 6. The observer must never full-scan on unrelated mutations (Codex round-5
// P2): pure-English streaming outside bubbles must not re-walk history.
if (/pendingFull/.test(w) || /scanAll\(\)/.test(w.split('Debounced watcher')[1] || '')) {
failures.push('observer regained a full-scan fallback (pendingFull/scanAll in mutation path)');
}
if (!/addedNodes/.test(w)) {
failures.push('observer no longer inspects addedNodes for new bubbles');
}

// 7. Anchors need their own inline bidi run (Codex round-3 P2, PR #2):
// unicode-bidi is not inherited, so block-level isolation alone leaves
// URL punctuation reorderable by the surrounding RTL context.
if (!/\[class\*="root_"\]:not\(\[class\*="thinkingContent_"\] \[class\*="root_"\]\) a \{\s*\n\s*unicode-bidi: plaintext;/.test(src)) {
failures.push('anchor unicode-bidi rule missing from Auto-mode CSS');
}

if (failures.length) {
console.error('FAIL — dir-walker scope regression:');
for (const f of failures) console.error(' - ' + f);
process.exit(1);
}
console.log('PASS — dir walker scoped to markdown roots, skip list covers all protected containers');