Skip to content
Prev Previous commit
fix(tui): correct mixed-direction BiDi ordering, isolation and wrapping
- Apply UAX #9 L2 flips over the full augmented stream including isolate
  controls, matching the reference application instead of clamping them
  away. Clamping rotated words and detached punctuation in mixed runs.
- Expand each island across adjacent Latin/digit words so one isolate
  covers every maximal LTR run; sibling sub-flips plus the whole-line
  flip otherwise swap neighboring runs.
- Broaden island seeds: markdown code spans, spaced Windows paths and
  key:line references. Code spans containing RTL prose stay natural.
- Prefer wrapping outside LTR islands so tokens are not split across
  visual lines unless unavoidable.
- Paint multi-unit graphemes with drawText: native setCell drops Arabic
  tashkeel. Shared paintBidiCell helper for text, textarea and markdown.
- Install markdown bidi paint on CodeRenderable itself so list items,
  blockquotes and streaming updates inherit it; fenced code and English
  keep the stock painter. Drop the now-redundant renderNode hook.
- Regression tests for mixed Arabic/English/code/path/URL/number runs,
  lists, links, diffs, wrapping and tashkeel in all three paint paths.
  • Loading branch information
mbeshirWaqoor committed Sep 13, 2026
commit aadf70993e3ee401426f8d66e08ba419542bd8c8
6 changes: 6 additions & 0 deletions packages/tui/src/component/bidi-elements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { extend } from "@opentui/solid"
import { BidiTextRenderable } from "./bidi-text"
import { BidiTextareaRenderable } from "./bidi-textarea"
import { installBidiCodePaint } from "./bidi-markdown"

// Registers the bidi-aware elements with the OpenTUI solid catalogue so they
// can be used as <bidi_text> and <bidi_textarea> in JSX. Import this module
Expand All @@ -11,11 +12,16 @@ import { BidiTextareaRenderable } from "./bidi-textarea"
// status lines all render through <text>. BidiTextRenderable defers to the
// stock painter whenever the content has no strong RTL characters, so
// English-only output is unchanged.
//
// installBidiCodePaint does the same for markdown code blocks at the
// CodeRenderable level, which is the only layer that sees list items,
// blockquotes and streaming updates (those paths never consult renderNode).
extend({
text: BidiTextRenderable,
bidi_text: BidiTextRenderable,
bidi_textarea: BidiTextareaRenderable,
})
installBidiCodePaint()

declare module "@opentui/solid" {
interface OpenTUIComponents {
Expand Down
166 changes: 96 additions & 70 deletions packages/tui/src/component/bidi-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,24 @@ import {
CodeRenderable,
TextBuffer,
type ChunkRenderContext,
type MarkdownOptions,
type OnChunksCallback,
type OptimizedBuffer,
type RenderNodeContext,
type TextChunk,
} from "@opentui/core"
import { hasRtl, layoutBidiText, widthOffsetToBoundary, wrappedLogicalText, type BidiLayout } from "../util/bidi"
import { hasRtl, layoutBidiText, paintBidiCell, widthOffsetToBoundary, wrappedLogicalText, type BidiLayout } from "../util/bidi"

// Markdown renderNode hook that installs bidi-aware painting on the text
// blocks (paragraph/heading) of a <markdown> element. Fenced code blocks,
// tables, diffs and every other block keep the stock LTR renderer, which is
// the required behavior for code.
// Markdown bidi painting, installed once on CodeRenderable itself so every
// markdown-prose block inherits it: top-level paragraphs and headings, list
// items, blockquotes and table fallbacks. OpenTUI builds those code blocks
// internally (list rows and streaming updates never consult renderNode), so
// per-instance patching always misses surfaces; the prototype sees them all.
//
// OpenTUI 0.4.5 offers renderNode as the only per-block override that
// preserves in-place streaming updates, so the hook patches the default
// renderable instance: it shadows renderSelf (the paint entrypoint) and
// wraps the onChunks callback to capture the tree-sitter styled chunks.
// Nothing else about the renderable changes: measurement, selection, copy
// and streaming reconciliation continue through the native text buffer,
// which is kept in sync with the wrapped logical text.
// Only blocks whose content carries strong RTL characters AND whose filetype
// is unset or "markdown" take the bidi path. Fenced code blocks carry a real
// filetype and English-only blocks have no RTL, so both keep the stock
// painter bit-for-bit. The logical string is never mutated: isolates live in
// a layout-only stream, and the native buffer only ever holds wrapped logical
// text, so selection and copy keep working.

type StyledSource = {
text: string
Expand All @@ -41,15 +39,17 @@ type BidiCodeState = {
// new styles) still rebuild the layout instead of painting a stale one.
styledVersion: number
paintedVersion: number
chunksWrapper: OnChunksCallback | undefined
}

const patched = new WeakSet<CodeRenderable>()
const blockStates = new WeakMap<CodeRenderable, BidiCodeState>()
let prototypePatched = false

// Protected members of TextBufferRenderable needed for buffer sync, plus
// the highlight machinery. startHighlight/_highlightsDirty are private in
// the .d.ts but public at runtime; these shapes only widen the TypeScript
// view. Coupled to @opentui/core 0.4.5 (see script/upgrade-opentui.ts when
// bumping).
// view. Coupled to the pinned @opentui/core (see script/upgrade-opentui.ts
// when bumping).
type CodeInternals = {
textBuffer: TextBuffer
plainText: string
Expand All @@ -58,47 +58,46 @@ type CodeInternals = {
_highlightsDirty?: boolean
}

export function bidiMarkdownRenderNode(token: { type: string }, context: RenderNodeContext) {
if (token.type !== "paragraph" && token.type !== "heading") return undefined
const renderable = context.defaultRender()
if (!(renderable instanceof CodeRenderable)) return renderable ?? undefined
applyBidiCodePaint(renderable)
return renderable
function blockState(renderable: CodeRenderable) {
let state = blockStates.get(renderable)
if (!state) {
state = {
styled: undefined,
layout: undefined,
source: "",
wrapped: undefined,
width: 0,
styledVersion: 0,
paintedVersion: -1,
chunksWrapper: undefined,
}
blockStates.set(renderable, state)
}
return state
}

type MarkdownNodeRenderer = NonNullable<MarkdownOptions["renderNode"]>

// Composes with an existing markdown renderNode (the TUI already passes
// `plugins.markdown()`). Custom plugin renderables win; everything else falls
// through to the bidi block renderer.
export function withBidiMarkdown(base?: MarkdownOptions["renderNode"]): MarkdownNodeRenderer {
return (token, context) => {
const custom = base?.(token, context)
if (custom !== undefined && custom !== null) return custom
return bidiMarkdownRenderNode(token, context)
}
function proseFiletype(renderable: CodeRenderable) {
return (renderable as unknown as { filetype?: unknown }).filetype
}

export function applyBidiCodePaint(renderable: CodeRenderable) {
if (patched.has(renderable)) return
patched.add(renderable)

const state: BidiCodeState = {
styled: undefined,
layout: undefined,
source: "",
wrapped: undefined,
width: 0,
styledVersion: 0,
paintedVersion: -1,
}
const internals = renderable as unknown as CodeInternals
const self = renderable as unknown as { renderSelf(buffer: OptimizedBuffer): void }
const originalRenderSelf = self.renderSelf.bind(renderable)
function shouldBidiPaint(renderable: CodeRenderable) {
if (renderable.width <= 0) return false
const filetype = proseFiletype(renderable)
if (filetype !== undefined && filetype !== "markdown") return false
const content = renderable.content
return typeof content === "string" && hasRtl(content)
}

const originalOnChunks: OnChunksCallback | undefined = renderable.onChunks
renderable.onChunks = async (chunks: TextChunk[], context: ChunkRenderContext) => {
const result = originalOnChunks ? await originalOnChunks(chunks, context) : undefined
// Wraps onChunks once per instance so tree-sitter styled chunks are captured
// for the paint below. Assigning marks highlights dirty, which restarts
// highlighting through the wrapper; afterwards the installed wrapper is
// detected and left alone, so this converges instead of looping.
function ensureChunksWrapped(renderable: CodeRenderable, state: BidiCodeState) {
const current = renderable.onChunks
if (current === state.chunksWrapper) return
const previous = current
const wrapper: OnChunksCallback = async (chunks: TextChunk[], context: ChunkRenderContext) => {
const result = previous ? await previous(chunks, context) : undefined
const captured = result ?? chunks
let text = ""
const offsets: number[] = []
Expand All @@ -112,24 +111,51 @@ export function applyBidiCodePaint(renderable: CodeRenderable) {
state.wrapped = undefined
return result
}
state.chunksWrapper = wrapper
renderable.onChunks = wrapper
}

self.renderSelf = (buffer: OptimizedBuffer) => {
const content = renderable.content
const plain = internals.plainText
// The buffer may hold our pre-wrapped text; recover the logical source.
const current = state.wrapped !== undefined && plain === state.wrapped ? state.source : plain
if (!hasRtl(content) || renderable.width <= 0) {
if (state.wrapped !== undefined) {
if (plain !== content) {
internals.textBuffer.setText(content)
internals.updateTextInfo()
}
state.wrapped = undefined
state.layout = undefined
}
originalRenderSelf(buffer)
export function installBidiCodePaint() {
if (prototypePatched) return
prototypePatched = true
const proto = CodeRenderable.prototype as unknown as {
renderSelf(buffer: OptimizedBuffer): void
}
const stockRenderSelf = proto.renderSelf
proto.renderSelf = function (this: CodeRenderable, buffer: OptimizedBuffer) {
if (!shouldBidiPaint(this)) {
stockRenderSelf.call(this, buffer)
return
}
const state = blockState(this)
ensureChunksWrapped(this, state)
paintBidiBlock(this, state, buffer, () => stockRenderSelf.call(this, buffer))
}
}

function paintBidiBlock(
renderable: CodeRenderable,
state: BidiCodeState,
buffer: OptimizedBuffer,
paintStock: () => void,
) {
const internals = renderable as unknown as CodeInternals
const content = renderable.content
const plain = internals.plainText
// The buffer may hold our pre-wrapped text; recover the logical source.
const current = state.wrapped !== undefined && plain === state.wrapped ? state.source : plain
if (!hasRtl(content) || renderable.width <= 0) {
if (state.wrapped !== undefined) {
if (plain !== content) {
internals.textBuffer.setText(content)
internals.updateTextInfo()
}
state.wrapped = undefined
state.layout = undefined
}
paintStock()
return
}

if (
state.source !== current ||
Expand Down Expand Up @@ -158,7 +184,6 @@ export function applyBidiCodePaint(renderable: CodeRenderable) {
}
paintStyledLines(buffer, state, renderable)
}
}

function paintStyledLines(buffer: OptimizedBuffer, state: BidiCodeState, renderable: CodeRenderable) {
const layout = state.layout
Expand Down Expand Up @@ -186,7 +211,8 @@ function paintStyledLines(buffer: OptimizedBuffer, state: BidiCodeState, rendera
if (x < 0 || x >= buffer.width) continue
const chunk = styled ? chunkAt(styled, layout.glyphs[cell.glyph].charIndex) : undefined
const selected = selStart >= 0 && cell.glyph >= selStart && cell.glyph < selEnd
buffer.setCell(
paintBidiCell(
buffer,
x,
y,
cell.char,
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/component/bidi-text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TextRenderable, type OptimizedBuffer } from "@opentui/core"
import { hasRtl, layoutBidiText, wrappedLogicalText, type BidiLayout } from "../util/bidi"
import { hasRtl, layoutBidiText, paintBidiCell, wrappedLogicalText, type BidiLayout } from "../util/bidi"

// RTL-aware text element for user-authored content (chat input echoes).
// English-only content renders through the stock OpenTUI path untouched; the
Expand Down Expand Up @@ -51,7 +51,7 @@ export class BidiTextRenderable extends TextRenderable {
for (const cell of line.cells) {
const x = x0 + cell.col
if (x < 0 || x >= buffer.width) continue
buffer.setCell(x, y, cell.char, fg, bg, attributes)
paintBidiCell(buffer, x, y, cell.char, fg, bg, attributes)
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/tui/src/component/bidi-textarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hasRtl,
layoutBidiText,
logicalCursorToBoundary,
paintBidiCell,
visualStep,
visualToBoundary,
widthOffsetToBoundary,
Expand Down Expand Up @@ -75,7 +76,8 @@ export class BidiTextareaRenderable extends TextareaRenderable {
const x = x0 + cell.col
if (x < 0 || x >= buffer.width) continue
const selected = selStart >= 0 && cell.glyph >= selStart && cell.glyph < selEnd
buffer.setCell(
paintBidiCell(
buffer,
x,
y,
cell.char,
Expand Down
6 changes: 2 additions & 4 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, CodeRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
SessionMessageInfo,
Expand Down Expand Up @@ -111,7 +111,6 @@ import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
import { groupRefs } from "./grouping/session"
import { applyBidiCodePaint, withBidiMarkdown } from "../../component/bidi-markdown"
import "../../component/bidi-elements"
export { InlineToolRow } from "./message-parts"

Expand Down Expand Up @@ -1835,7 +1834,6 @@ function SessionReasoningGroupView(props: {
paddingLeft={1}
>
<code
ref={(el: CodeRenderable) => applyBidiCodePaint(el)}
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
Expand Down Expand Up @@ -2114,7 +2112,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
renderNode={withBidiMarkdown(plugins.markdown())}
renderNode={plugins.markdown()}
streaming={true}
internalBlockMode="top-level"
content={content()}
Expand Down
3 changes: 1 addition & 2 deletions packages/tui/src/routes/session/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
import { withBidiMarkdown } from "../../component/bidi-markdown"
import "../../component/bidi-elements"

export const INLINE_TOOL_ICON_WIDTH = 2
Expand Down Expand Up @@ -161,7 +160,7 @@ export function TextPart(props: {
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={withBidiMarkdown(plugins.markdown())}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
Expand Down
Loading
Loading