From b9e9fbc97cf003a86398a123f86f31e8470f235d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 22 Feb 2026 01:09:51 -0800 Subject: [PATCH] TUI: preserve RTL text order in terminal output --- CHANGELOG.md | 1 + src/tui/tui-formatters.test.ts | 21 +++++++++++++++++++++ src/tui/tui-formatters.ts | 26 ++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4dc0bfa15..c41b53b725c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. - Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. - TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. +- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96. - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index d14ed6d0abb..e9ed51ec8de 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -251,4 +251,25 @@ describe("sanitizeRenderableText", () => { expect(sanitized).toBe(input); }); + + it("wraps rtl lines with directional isolation marks", () => { + const input = "مرحبا بالعالم"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe("\u2067مرحبا بالعالم\u2069"); + }); + + it("only wraps lines that contain rtl script", () => { + const input = "hello\nمرحبا"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe("hello\n\u2067مرحبا\u2069"); + }); + + it("does not double-wrap lines that already include bidi controls", () => { + const input = "\u2067مرحبا\u2069"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); }); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index ae52e3b377a..a05152c9a5a 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -11,6 +11,10 @@ const BINARY_LINE_REPLACEMENT_THRESHOLD = 12; const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; +const RTL_SCRIPT_RE = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]/; +const BIDI_CONTROL_RE = /[\u202a-\u202e\u2066-\u2069]/; +const RTL_ISOLATE_START = "\u2067"; +const RTL_ISOLATE_END = "\u2069"; function hasControlChars(text: string): boolean { for (const char of text) { @@ -91,6 +95,23 @@ function redactBinaryLikeLine(line: string): string { return line; } +function isolateRtlLine(line: string): string { + if (!RTL_SCRIPT_RE.test(line) || BIDI_CONTROL_RE.test(line)) { + return line; + } + return `${RTL_ISOLATE_START}${line}${RTL_ISOLATE_END}`; +} + +function applyRtlIsolation(text: string): string { + if (!RTL_SCRIPT_RE.test(text)) { + return text; + } + return text + .split("\n") + .map((line) => isolateRtlLine(line)) + .join("\n"); +} + export function sanitizeRenderableText(text: string): string { if (!text) { return text; @@ -101,7 +122,7 @@ export function sanitizeRenderableText(text: string): string { const hasLongTokens = LONG_TOKEN_TEST_RE.test(text); const hasControls = hasControlChars(text); if (!hasAnsi && !hasReplacementChars && !hasLongTokens && !hasControls) { - return text; + return applyRtlIsolation(text); } const withoutAnsi = hasAnsi ? stripAnsi(text) : text; @@ -112,9 +133,10 @@ export function sanitizeRenderableText(text: string): string { .map((line) => redactBinaryLikeLine(line)) .join("\n") : withoutControlChars; - return LONG_TOKEN_TEST_RE.test(redacted) + const tokenSafe = LONG_TOKEN_TEST_RE.test(redacted) ? redacted.replace(LONG_TOKEN_RE, normalizeLongTokenForDisplay) : redacted; + return applyRtlIsolation(tokenSafe); } export function resolveFinalAssistantText(params: {