diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index adfd592f8d0..9e864b12e6d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -52,6 +52,7 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { isRtlText } from "@opencode-ai/ui/message-part" interface PromptInputProps { class?: string @@ -406,6 +407,24 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + const [isRTL, setIsRTL] = createSignal(false) + createEffect(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + setIsRTL(isRtlText(text)) + }) + + createEffect(() => { + if (!isFocused()) closePopover() + }) + + // Safety: reset composing state on focus change to prevent stuck state + // This handles edge cases where compositionend event may not fire + createEffect(() => { + if (!isFocused()) setComposing(false) + }) const handleBlur = () => { closePopover() setComposing(false) @@ -1135,12 +1154,14 @@ export const PromptInput: Component = (props) => { onCompositionEnd={() => setComposing(false)} onBlur={handleBlur} onKeyDown={handleKeyDown} + dir={isRTL() ? "rtl" : "ltr"} classList={{ "select-text": true, "w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", + "text-right": isRTL(), }} /> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index adba42ce930..c2887b14e39 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -70,6 +70,26 @@ function getDiagnostics( return diagnostics.filter((d) => d.severity === 1).slice(0, 3) } +export const isRtlText = (text: string) => { + const rtlChars = /[\u0590-\u05FF\u0600-\u06FF]/ + let rtlCount = 0 + let totalCount = 0 + + for (const char of text) { + if (char.trim() !== "") { + totalCount++ + if (rtlChars.test(char)) { + rtlCount++ + + } + } + } + + if (totalCount < 3) return false + + return rtlCount / totalCount > 0.5 +} + function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { const i18n = useI18n() return ( @@ -676,6 +696,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp ) const text = createMemo(() => textPart()?.text || "") + const [isRTL, setIsRTL] = createSignal(false) + createEffect(() => { + setIsRTL(isRtlText(text())) + }) const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) @@ -773,7 +797,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp <>
-
+
@@ -1146,6 +1170,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { }) const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) + const [isRTL, setIsRTL] = createSignal(false) + createEffect(() => { + setIsRTL(isRtlText(displayText())) + }) const throttledText = createThrottledValue(displayText) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) @@ -1171,7 +1199,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return ( -
+