diff --git a/bun.lock b/bun.lock index 30b8f7f4d5e8..cc75a550fc3f 100644 --- a/bun.lock +++ b/bun.lock @@ -912,6 +912,7 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", + "bidi-js": "1.1.0", "effect": "catalog:", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", @@ -3508,6 +3509,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 96396aeab179..140e38a175a4 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -90,6 +90,7 @@ "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/event-bus": "1.1.2", + "bidi-js": "1.1.0", "effect": "catalog:", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 976eef49af17..e6682565a5fb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -63,6 +63,7 @@ import { DialogMcp } from "./component/dialog-mcp" import { DialogStatus } from "./component/dialog-status" import { DialogConfig } from "./component/dialog-config" import { DialogDebug } from "./component/dialog-debug" +import "./component/bidi-elements" import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" diff --git a/packages/tui/src/component/bidi-elements.ts b/packages/tui/src/component/bidi-elements.ts new file mode 100644 index 000000000000..e49d040f540d --- /dev/null +++ b/packages/tui/src/component/bidi-elements.ts @@ -0,0 +1,31 @@ +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 and in JSX. Import this module +// from any component that renders them. +// +// Overriding the built-in `text` component is what makes RTL work "everywhere" +// without touching every call site: dialogs, lists, tool output, toasts and +// status lines all render through . 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 { + 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..1e981eb953c4 --- /dev/null +++ b/packages/tui/src/component/bidi-markdown.ts @@ -0,0 +1,237 @@ +import { + CodeRenderable, + TextBuffer, + type ChunkRenderContext, + type OnChunksCallback, + type OptimizedBuffer, + type TextChunk, +} from "@opentui/core" +import { hasRtl, layoutBidiText, paintBidiCell, widthOffsetToBoundary, wrappedLogicalText, type BidiLayout } from "../util/bidi" + +// 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. +// +// 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 + 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 + chunksWrapper: OnChunksCallback | undefined +} + +const blockStates = new WeakMap() +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 the pinned @opentui/core (see script/upgrade-opentui.ts +// when bumping). +type CodeInternals = { + textBuffer: TextBuffer + plainText: string + updateTextInfo(): void + startHighlight(): void + _highlightsDirty?: boolean +} + +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 +} + +function proseFiletype(renderable: CodeRenderable) { + return (renderable as unknown as { filetype?: unknown }).filetype +} + +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) +} + +// 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[] = [] + 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 + } + state.chunksWrapper = wrapper + renderable.onChunks = wrapper +} + +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 || + 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 + paintBidiCell( + buffer, + 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..9c1077808cdb --- /dev/null +++ b/packages/tui/src/component/bidi-text.ts @@ -0,0 +1,66 @@ +import { TextRenderable, type OptimizedBuffer } from "@opentui/core" +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 +// 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 + paintBidiCell(buffer, 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..987fab82be42 --- /dev/null +++ b/packages/tui/src/component/bidi-textarea.ts @@ -0,0 +1,186 @@ +import { TextareaRenderable, type OptimizedBuffer } from "@opentui/core" +import { + boundaryToLogicalCursor, + boundaryToVisual, + hasRtl, + layoutBidiText, + logicalCursorToBoundary, + paintBidiCell, + 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 + paintBidiCell( + buffer, + 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 e202c026c359..89dbf9e1db79 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -69,6 +69,7 @@ import { directoryRecentValue } from "../../prompt/directory-completion" import { useWorkingDirectoryActions } from "../../ui/working-directory-actions" import { truncateFilePath } from "../../ui/file-path" import { PromptMetadataRow } from "./metadata" +import "../bidi-elements" export type PromptProps = { sessionID?: string @@ -1757,7 +1758,7 @@ export function Prompt(props: PromptProps) { -