TUI: preserve RTL text order in terminal output

This commit is contained in:
Vignesh Natarajan
2026-02-22 01:09:51 -08:00
parent aa2b16abe8
commit b9e9fbc97c
3 changed files with 46 additions and 2 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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: {