From bc416786ff552542e73b3fbe9625403ce55073df Mon Sep 17 00:00:00 2001 From: muhamedbeshir Date: Sat, 12 Sep 2026 05:20:49 +0300 Subject: [PATCH 01/10] feat(tui): native bidi/RTL rendering for prompts and messages Render Arabic, Persian, Urdu and Hebrew text correctly in the native TUI using UAX #9, without terminal-side bidi support or destructive text rewriting. - Add bidi-js based layout engine (direction detection via first strong char, LRI/PDI isolation for URLs/paths/numbers/code spans, grapheme- and width-aware wrapping, logical<->visual caret maps). - Add BidiTextareaRenderable for the prompt: visual caret placement and visual arrow-key motion while the edit buffer stays logical Unicode. - Add a markdown renderNode hook for assistant paragraphs/headings; fenced code, tables and diffs keep the stock LTR renderer. - Add BidiTextRenderable for user message echoes. - English-only content takes the stock render path unchanged; selection/copy return logical text. - Tests: engine unit tests, real test-renderer component tests, and an app-level e2e that types Arabic via real key presses and streams an assistant reply. --- bun.lock | 3 + packages/tui/package.json | 1 + packages/tui/src/component/bidi-elements.ts | 18 + packages/tui/src/component/bidi-markdown.ts | 197 +++++++ packages/tui/src/component/bidi-text.ts | 66 +++ packages/tui/src/component/bidi-textarea.ts | 184 ++++++ packages/tui/src/component/prompt/index.tsx | 3 +- packages/tui/src/routes/session/index.tsx | 5 +- packages/tui/src/util/bidi.ts | 537 ++++++++++++++++++ packages/tui/test/bidi-e2e.test.tsx | 223 ++++++++ .../tui/test/component/bidi-render.test.tsx | 271 +++++++++ packages/tui/test/util/bidi.test.ts | 208 +++++++ 12 files changed, 1714 insertions(+), 2 deletions(-) create mode 100644 packages/tui/src/component/bidi-elements.ts create mode 100644 packages/tui/src/component/bidi-markdown.ts create mode 100644 packages/tui/src/component/bidi-text.ts create mode 100644 packages/tui/src/component/bidi-textarea.ts create mode 100644 packages/tui/src/util/bidi.ts create mode 100644 packages/tui/test/bidi-e2e.test.tsx create mode 100644 packages/tui/test/component/bidi-render.test.tsx create mode 100644 packages/tui/test/util/bidi.test.ts diff --git a/bun.lock b/bun.lock index efe01bf957f3..3e1199267aa9 100644 --- a/bun.lock +++ b/bun.lock @@ -948,6 +948,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "bidi-js": "1.1.0", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", @@ -3196,6 +3197,8 @@ "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], diff --git a/packages/tui/package.json b/packages/tui/package.json index 4b9b95b35e68..ef8b04443fc7 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -55,6 +55,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "bidi-js": "1.1.0", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/tui/src/component/bidi-elements.ts b/packages/tui/src/component/bidi-elements.ts new file mode 100644 index 000000000000..a276563d3c8c --- /dev/null +++ b/packages/tui/src/component/bidi-elements.ts @@ -0,0 +1,18 @@ +import { extend } from "@opentui/solid" +import { BidiTextRenderable } from "./bidi-text" +import { BidiTextareaRenderable } from "./bidi-textarea" + +// Registers the bidi-aware elements with the OpenTUI solid catalogue so they +// can be used as and in JSX. Import this module +// from any component that renders them. +extend({ + bidi_text: BidiTextRenderable, + bidi_textarea: BidiTextareaRenderable, +}) + +declare module "@opentui/solid" { + interface OpenTUIComponents { + bidi_text: typeof BidiTextRenderable + bidi_textarea: typeof BidiTextareaRenderable + } +} diff --git a/packages/tui/src/component/bidi-markdown.ts b/packages/tui/src/component/bidi-markdown.ts new file mode 100644 index 000000000000..2813b248da02 --- /dev/null +++ b/packages/tui/src/component/bidi-markdown.ts @@ -0,0 +1,197 @@ +import { + CodeRenderable, + TextBuffer, + type ChunkRenderContext, + type OnChunksCallback, + type OptimizedBuffer, + type RenderNodeContext, + type TextChunk, +} from "@opentui/core" +import { hasRtl, layoutBidiText, widthOffsetToBoundary, wrappedLogicalText, type BidiLayout } from "../util/bidi" + +// Markdown renderNode hook that installs bidi-aware painting on the text +// blocks (paragraph/heading) of a element. Fenced code blocks, +// tables, diffs and every other block keep the stock LTR renderer, which is +// the required behavior for code. +// +// 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. + +type StyledSource = { + text: string + chunks: TextChunk[] + // Char offset where each chunk starts within text. + offsets: number[] +} + +type BidiCodeState = { + styled: StyledSource | undefined + layout: BidiLayout | undefined + // Logical source text of the current layout (pre-wrap). + source: string + wrapped: string | undefined + width: number + // Bumped on every captured chunk update so style-only changes (same text, + // new styles) still rebuild the layout instead of painting a stale one. + styledVersion: number + paintedVersion: number +} + +const patched = new WeakSet() + +// 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). +type CodeInternals = { + textBuffer: TextBuffer + plainText: string + updateTextInfo(): void + startHighlight(): void + _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 +} + +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) + + const originalOnChunks: OnChunksCallback | undefined = renderable.onChunks + renderable.onChunks = async (chunks: TextChunk[], context: ChunkRenderContext) => { + const result = originalOnChunks ? await originalOnChunks(chunks, context) : undefined + const captured = result ?? chunks + let text = "" + const offsets: number[] = [] + for (const chunk of captured) { + offsets.push(text.length) + text += chunk.text + } + state.styled = { text, chunks: captured, offsets } + state.styledVersion++ + state.layout = undefined + state.wrapped = undefined + return result + } + + 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) + return + } + + if ( + state.source !== current || + state.width !== renderable.width || + state.paintedVersion !== state.styledVersion + ) { + state.source = current + state.width = renderable.width + state.paintedVersion = state.styledVersion + state.layout = layoutBidiText(current, renderable.width) + state.wrapped = wrappedLogicalText(state.layout) + } + + // The patched paint replaces Code's renderSelf, which normally starts + // tree-sitter highlighting when dirty. Mirror that kickoff exactly + // (clear the flag synchronously, then start) so syntax colors keep + // flowing for RTL blocks on content, theme, and conceal changes. + if (internals._highlightsDirty) { + internals._highlightsDirty = false + internals.startHighlight() + } + + if (plain !== state.wrapped) { + internals.textBuffer.setText(state.wrapped ?? current) + internals.updateTextInfo() + } + paintStyledLines(buffer, state, renderable) + } +} + +function paintStyledLines(buffer: OptimizedBuffer, state: BidiCodeState, renderable: CodeRenderable) { + const layout = state.layout + if (!layout) return + // Styles apply only when the captured chunk text still matches the layout + // source; otherwise the paint falls back to the renderable defaults (the + // brief streaming window before tree-sitter styling lands). + const styled = state.styled && state.styled.text === state.source ? state.styled : undefined + const defaultFg = renderable.fg + const defaultBg = renderable.bg + const defaultAttributes = renderable.attributes + const selection = renderable.getSelection() + const selectionFg = renderable.selectionFg ?? defaultFg + const selectionBg = renderable.selectionBg ?? defaultBg + const selStart = selection ? widthOffsetToBoundary(layout, selection.start) : -1 + const selEnd = selection ? widthOffsetToBoundary(layout, selection.end) : -1 + const width = renderable.width + for (let i = 0; i < layout.lines.length; i++) { + const line = layout.lines[i] + const y = renderable.screenY + i + if (y < 0 || y >= buffer.height) continue + const x0 = line.rtl ? renderable.screenX + width - line.width : renderable.screenX + for (const cell of line.cells) { + const x = x0 + cell.col + 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( + x, + y, + cell.char, + selected ? selectionFg : (chunk?.fg ?? defaultFg), + selected ? selectionBg : (chunk?.bg ?? defaultBg), + chunk?.attributes ?? defaultAttributes, + ) + } + } +} + +function chunkAt(source: StyledSource, charIndex: number) { + let lo = 0 + let hi = source.offsets.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (source.offsets[mid] <= charIndex) lo = mid + else hi = mid - 1 + } + const chunk = source.chunks[lo] + return chunk && charIndex < source.offsets[lo] + chunk.text.length ? chunk : undefined +} diff --git a/packages/tui/src/component/bidi-text.ts b/packages/tui/src/component/bidi-text.ts new file mode 100644 index 000000000000..5b49523c84cf --- /dev/null +++ b/packages/tui/src/component/bidi-text.ts @@ -0,0 +1,66 @@ +import { TextRenderable, type OptimizedBuffer } from "@opentui/core" +import { hasRtl, layoutBidiText, 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 +// bidi layout runs only when strong RTL characters are present. The logical +// string is never mutated: the native text buffer is only re-synced to the +// wrapped logical form so measurement, selection and copy keep working. +export class BidiTextRenderable extends TextRenderable { + private bidiSource: string | undefined + private bidiWrapped: string | undefined + private bidiLayout: BidiLayout | undefined + private bidiWidth = 0 + + protected override renderSelf(buffer: OptimizedBuffer): void { + const plain = this.plainText + if (this.bidiWrapped !== undefined && plain !== this.bidiWrapped && plain !== this.bidiSource) { + // Content was updated externally; adopt the buffer text as the new + // logical source before any wrapping decision. + this.bidiSource = plain + this.bidiLayout = undefined + } + const source = this.bidiSource ?? plain + if (!hasRtl(source) || this.width <= 0) { + this.restoreLogical(source, plain) + super.renderSelf(buffer) + return + } + + if (!this.bidiLayout || this.bidiWidth !== this.width) { + this.bidiLayout = layoutBidiText(source, this.width) + this.bidiWidth = this.width + this.bidiWrapped = wrappedLogicalText(this.bidiLayout) + this.bidiSource = source + } + + if (plain !== this.bidiWrapped) { + this.textBuffer.setText(this.bidiWrapped ?? source) + this.updateTextInfo() + } + + const layout = this.bidiLayout + const fg = this.fg + const bg = this.bg + const attributes = this.attributes + for (let i = 0; i < layout.lines.length; i++) { + const line = layout.lines[i] + const y = this._screenY + i + if (y < 0 || y >= buffer.height) continue + const x0 = line.rtl ? this._screenX + this.width - line.width : this._screenX + 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) + } + } + } + + private restoreLogical(source: string, plain: string) { + if (this.bidiWrapped === undefined || plain !== this.bidiWrapped) return + this.textBuffer.setText(source) + this.updateTextInfo() + this.bidiWrapped = undefined + this.bidiLayout = undefined + } +} diff --git a/packages/tui/src/component/bidi-textarea.ts b/packages/tui/src/component/bidi-textarea.ts new file mode 100644 index 000000000000..efd148da8333 --- /dev/null +++ b/packages/tui/src/component/bidi-textarea.ts @@ -0,0 +1,184 @@ +import { TextareaRenderable, type OptimizedBuffer } from "@opentui/core" +import { + boundaryToLogicalCursor, + boundaryToVisual, + hasRtl, + layoutBidiText, + logicalCursorToBoundary, + visualStep, + visualToBoundary, + widthOffsetToBoundary, + type BidiLayout, +} from "../util/bidi" + +// RTL-aware prompt input. The edit buffer always stores logical Unicode text +// (typing, paste, undo and value reads are untouched); only painting, caret +// placement and left/right/up/down motion are bidi-aware. When the text has +// no strong RTL characters every code path defers to the stock OpenTUI +// textarea, so English-only behavior is bit-for-bit unchanged. +export class BidiTextareaRenderable extends TextareaRenderable { + private bidiState: { source: string; width: number; layout: BidiLayout; scroll: number } | undefined + private bidiDesiredCol: number | undefined + + private bidiLayout() { + const source = this.plainText + if (!hasRtl(source)) { + this.bidiState = undefined + return undefined + } + const width = this.width + if (width <= 0) return undefined + if (this.bidiState && this.bidiState.source === source && this.bidiState.width === width) return this.bidiState + this.bidiState = { source, width, layout: layoutBidiText(source, width), scroll: this.bidiState?.scroll ?? 0 } + return this.bidiState + } + + private bidiViewportHeight() { + return Math.max(1, Math.floor(this.height)) + } + + private clampBidiScroll(state: NonNullable) { + const height = this.bidiViewportHeight() + state.scroll = Math.max(0, Math.min(state.scroll, Math.max(0, state.layout.lines.length - height))) + } + + private currentBoundary(state: NonNullable) { + const cursor = this.editBuffer.getCursorPosition() + return logicalCursorToBoundary(state.layout, cursor.row, cursor.col) + } + + private moveToBoundary(layout: BidiLayout, boundary: number) { + const position = boundaryToLogicalCursor(layout, boundary) + this.editBuffer.setCursor(position.row, position.col) + this.bidiDesiredCol = undefined + } + + protected override renderSelf(buffer: OptimizedBuffer): void { + const state = this.bidiLayout() + if (!state) { + super.renderSelf(buffer) + return + } + this.clampBidiScroll(state) + const selection = this.getSelection() + const selStart = selection ? widthOffsetToBoundary(state.layout, selection.start) : -1 + const selEnd = selection ? widthOffsetToBoundary(state.layout, selection.end) : -1 + const selectionFg = this._selectionFg ?? this._textColor + const selectionBg = this._selectionBg ?? this._backgroundColor + const height = this.bidiViewportHeight() + for (let i = state.scroll; i < state.layout.lines.length && i < state.scroll + height; i++) { + const line = state.layout.lines[i] + const y = this._screenY + (i - state.scroll) + if (y < 0 || y >= buffer.height) continue + const x0 = line.rtl ? this._screenX + state.width - line.width : this._screenX + for (const cell of line.cells) { + const x = x0 + cell.col + if (x < 0 || x >= buffer.width) continue + const selected = selStart >= 0 && cell.glyph >= selStart && cell.glyph < selEnd + buffer.setCell( + x, + y, + cell.char, + selected ? selectionFg : this._textColor, + selected ? selectionBg : this._backgroundColor, + this._defaultAttributes, + ) + } + } + } + + protected override renderCursor(buffer: OptimizedBuffer): void { + const state = this.bidiLayout() + if (!state) { + super.renderCursor(buffer) + return + } + if (!this._showCursor || !this._focused) return + const boundary = this.currentBoundary(state) + const position = boundaryToVisual(state.layout, boundary) + const height = this.bidiViewportHeight() + if (position.line < state.scroll) state.scroll = position.line + if (position.line >= state.scroll + height) state.scroll = position.line - height + 1 + this.clampBidiScroll(state) + const line = state.layout.lines[position.line] + const x = (line.rtl ? this._screenX + state.width - line.width : this._screenX) + position.col + this._ctx.setCursorPosition(x + 1, this._screenY + (position.line - state.scroll) + 1, true) + this._ctx.setCursorStyle({ ...this._cursorStyle, color: this._cursorColor }) + void buffer + } + + override moveCursorLeft(options?: { select?: boolean }): boolean { + const state = this.bidiLayout() + if (!state) return super.moveCursorLeft(options) + const select = options?.select ?? false + if (!select && this.hasSelection()) return super.moveCursorLeft(options) + this.updateSelectionForMovement(select, true) + this.moveToBoundary(state.layout, visualStep(state.layout, this.currentBoundary(state), -1)) + this.updateSelectionForMovement(select, false) + this.requestRender() + return true + } + + override moveCursorRight(options?: { select?: boolean }): boolean { + const state = this.bidiLayout() + if (!state) return super.moveCursorRight(options) + const select = options?.select ?? false + if (!select && this.hasSelection()) return super.moveCursorRight(options) + this.updateSelectionForMovement(select, true) + this.moveToBoundary(state.layout, visualStep(state.layout, this.currentBoundary(state), 1)) + this.updateSelectionForMovement(select, false) + this.requestRender() + return true + } + + override moveCursorUp(options?: { select?: boolean }): boolean { + const state = this.bidiLayout() + if (!state) return super.moveCursorUp(options) + const select = options?.select ?? false + this.updateSelectionForMovement(select, true) + const boundary = this.currentBoundary(state) + const position = boundaryToVisual(state.layout, boundary) + if (position.line === 0) { + this.editBuffer.setCursor(0, 0) + } else { + const desired = this.bidiDesiredCol ?? position.col + this.moveToBoundary(state.layout, visualToBoundary(state.layout, position.line - 1, desired)) + this.bidiDesiredCol = desired + } + this.updateSelectionForMovement(select, false) + this.requestRender() + return true + } + + override moveCursorDown(options?: { select?: boolean }): boolean { + const state = this.bidiLayout() + if (!state) return super.moveCursorDown(options) + const select = options?.select ?? false + this.updateSelectionForMovement(select, true) + const boundary = this.currentBoundary(state) + const position = boundaryToVisual(state.layout, boundary) + if (position.line >= state.layout.lines.length - 1) { + const last = state.layout.paragraphs[state.layout.paragraphs.length - 1] + this.editBuffer.setCursor(state.layout.paragraphs.length - 1, state.layout.boundaryWidths[last.end] - last.startWidth) + } else { + const desired = this.bidiDesiredCol ?? position.col + this.moveToBoundary(state.layout, visualToBoundary(state.layout, position.line + 1, desired)) + this.bidiDesiredCol = desired + } + this.updateSelectionForMovement(select, false) + this.requestRender() + return true + } + + protected override handleScroll(event: { type: string; scroll?: { direction: string; delta: number } }): void { + super.handleScroll(event) + const state = this.bidiLayout() + if (!state || !event.scroll) return + const delta = event.scroll.delta + if (event.scroll.direction === "up") state.scroll = Math.max(0, state.scroll - delta) + if (event.scroll.direction === "down") { + state.scroll = Math.min(state.scroll + delta, Math.max(0, state.layout.lines.length - this.bidiViewportHeight())) + } + this.requestRender() + } +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c48c751739ce..a3598c9b98ca 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -57,6 +57,7 @@ import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useLocation } from "../../context/location" +import "../../component/bidi-elements" registerOpencodeSpinner() @@ -1366,7 +1367,7 @@ export function Prompt(props: PromptProps) { flexGrow={1} width="100%" > -