Files
CodeNomad/packages/ui/src/components/prompt-input.tsx
MusiCode1 09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## What and why

CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew
or Arabic would see their messages displayed left-to-right — misaligned
text, broken reading flow, wrong punctuation placement.

This PR adds automatic direction detection to all elements that display
user or model text. The browser detects direction from the first strong
character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No
configuration needed — it just works per message, per paragraph.

## Technical notes

The natural fix is `dir="auto"` on the containing elements. However,
Chromium does not propagate direction detection from a parent `<div>`
into its `<p>` children — so Hebrew inside `<p>` rendered via
`innerHTML` (as markdown is) was still detected as LTR. The fix is to
apply `unicode-bidi: plaintext` via CSS directly on the block-level
elements (`p`, `li`, headings, etc.), which has the same auto-detection
semantics but applies per element.

## Summary

- Add `dir="auto"` to all elements containing user-generated or
model-generated text (message content, prompt input, session names, tool
outputs) so the browser auto-detects text direction
- Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`,
`li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL
detection in Chromium (where `dir="auto"` on a parent div does not
recurse into block children)
- Convert physical CSS properties to logical equivalents in
`markdown.css`: `border-left` → `border-inline-start`, `padding-left` →
`padding-inline-start`, `text-align: left` → `text-align: start`,
`margin-left` → `margin-inline-start`

## Affected components

- `markdown.tsx` — main markdown renderer
- `message-part.tsx` — text part wrapper and plain-text fallback
- `message-item.tsx` — message body and error blocks
- `prompt-input.tsx` — user input textarea
- `session-list.tsx` — session titles in sidebar
- `session-rename-dialog.tsx` — session rename input
- `instance-welcome-view.tsx` — Resume Session dialog
- `tool-call/markdown-render.tsx` — tool output markdown fallback
- `tool-call/ansi-render.tsx` — ANSI output
- `tool-call/diagnostics-section.tsx` — diagnostic messages

## Test plan

- [ ] Send a Hebrew-only message → text right-aligned
- [ ] Send a mixed Hebrew + English message → correct per-paragraph
direction
- [ ] Message containing a code block → code stays LTR
- [ ] Type Hebrew in the prompt textarea → input flows right-to-left
- [ ] Hebrew session name in sidebar → right-aligned
- [ ] Hebrew session name in Resume Session dialog → right-aligned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:18:24 +00:00

632 lines
22 KiB
TypeScript

import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
const log = getLogger("actions")
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
const usedCounters = new Set<string>()
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
const counter = match?.[1]
if (counter) usedCounters.add(counter)
}
if (usedCounters.size === 0) return []
const consumed = new Set<string>()
for (const attachment of attachments) {
if (!attachment?.id) continue
if (attachment?.source?.type !== "text") continue
const display = attachment.display
if (typeof display !== "string") continue
const match = display.match(pastedDisplayCounterRegex)
if (!match?.[1]) continue
if (usedCounters.has(match[1])) {
consumed.add(attachment.id)
}
}
return Array.from(consumed)
}
export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n()
const [, setIsFocused] = createSignal(false)
const [mode, setMode] = createSignal<PromptMode>("normal")
const [expandState, setExpandState] = createSignal<ExpandState>("normal")
const SELECTION_INSERT_MAX_LENGTH = 2000
let textareaRef: HTMLTextAreaElement | undefined
const getPlaceholder = () => {
if (mode() === "shell") {
return t("promptInput.placeholder.shell")
}
return t("promptInput.placeholder.default")
}
const promptState = usePromptState({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
instanceFolder: () => props.instanceFolder,
})
const {
prompt,
setPrompt,
clearPrompt,
draftLoadedNonce,
history,
historyIndex,
recordHistoryEntry,
clearHistoryDraft,
resetHistoryNavigation,
selectPreviousHistory,
selectNextHistory,
} = promptState
const {
attachments,
isDragging,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
syncAttachmentCounters,
handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
instanceFolder: () => props.instanceFolder,
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
})
createEffect(() => {
if (!props.registerPromptInputApi) return
const api: PromptInputApi = {
insertSelection: (text: string, mode: PromptInsertMode) => {
if (mode === "code") {
insertCodeSelection(text)
} else {
insertQuotedSelection(text)
}
},
expandTextAttachment: (attachmentId: string) => {
const attachment = attachments().find((a) => a.id === attachmentId)
if (!attachment) return
handleExpandTextAttachment(attachment)
},
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef
if (textarea) {
textarea.value = text
textarea.dispatchEvent(new Event("input", { bubbles: true }))
if (opts?.focus) {
try {
textarea.focus({ preventScroll: true } as any)
} catch {
textarea.focus()
}
}
return
}
setPrompt(text)
if (opts?.focus) {
setTimeout(() => {
api.focus()
}, 0)
}
},
focus: () => {
const textarea = textareaRef
if (!textarea || textarea.disabled) return
try {
textarea.focus({ preventScroll: true } as any)
} catch {
textarea.focus()
}
},
}
const cleanup = props.registerPromptInputApi(api)
onCleanup(() => {
if (typeof cleanup === "function") {
cleanup()
}
})
})
const instanceAgents = () => agents().get(props.instanceId) || []
const promptPicker = usePromptPicker({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
instanceFolder: () => props.instanceFolder,
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
instanceAgents,
commands: () => getCommands(props.instanceId),
})
const {
showPicker,
pickerMode,
searchQuery,
ignoredAtPositions,
setShowPicker,
setPickerMode,
setSearchQuery,
setAtPosition,
setIgnoredAtPositions,
handleInput,
handlePickerSelect,
handlePickerClose,
} = promptPicker
createEffect(
on(
draftLoadedNonce,
() => {
// Session switch resets (picker/counters/ignored positions) stay in the component.
setIgnoredAtPositions(new Set<number>())
setShowPicker(false)
setPickerMode("mention")
setAtPosition(null)
setSearchQuery("")
syncAttachmentCounters(prompt())
},
{ defer: true },
),
)
const isCoarsePointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
if (isCoarsePointer()) return
if (props.isActive === false) return
if (props.disabled) return
const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null
const isInputElement =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" ||
Boolean(activeElement?.isContentEditable)
if (isInputElement) return
const isModifierKey = e.ctrlKey || e.metaKey || e.altKey
if (isModifierKey) return
const isSpecialKey =
e.key === "Tab" ||
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return
const textarea = textareaRef
if (!textarea || textarea.disabled) return
// In session cache mode inactive panes are display:none; avoid stealing focus.
if (textarea.offsetParent === null) return
if (e.key.length === 1) {
textarea.focus()
}
}
document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown)
})
})
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (props.disabled || (!text && currentAttachments.length === 0)) return
const isShellMode = mode() === "shell"
// Slash command routing (match OpenCode TUI): only run if the command exists.
const isSlashCandidate = !isShellMode && text.startsWith("/")
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : ""
const commandName = isSlashCandidate ? commandToken.slice(1) : ""
const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : ""
const isKnownSlashCommand =
isSlashCandidate &&
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry)
setExpandState("normal")
clearPrompt()
clearHistoryDraft()
setMode("normal")
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
} else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
}
clearHistoryDraft()
if (isKnownSlashCommand) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
await props.onRunShell(resolvedPrompt)
} else {
await props.onSend(resolvedPrompt, [])
}
} else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
} else {
await props.onSend(resolvedPrompt, currentAttachments)
}
if (!isKnownSlashCommand) {
void refreshHistory()
}
} catch (error) {
log.error("Failed to send message:", error)
showAlertDialog(t("promptInput.send.errorFallback"), {
title: t("promptInput.send.errorTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
textareaRef?.focus()
}
}
function handleAbort() {
if (!props.onAbortSession || !props.isSessionBusy) return
void props.onAbortSession()
}
function handleExpandToggle(nextState: "normal" | "expanded") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function insertBlockContent(block: string) {
const textarea = textareaRef
const current = prompt()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.substring(0, start)
const after = current.substring(end)
const needsLeading = before.length > 0 && !before.endsWith("\n") ? "\n" : ""
const insertion = `${needsLeading}${block}`
const nextValue = before + insertion + after
setPrompt(nextValue)
setShowPicker(false)
setAtPosition(null)
if (textarea) {
setTimeout(() => {
const cursor = before.length + insertion.length
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function insertQuotedSelection(rawText: string) {
const normalized = (rawText ?? "").replace(/\r/g, "").trim()
if (!normalized) return
const limited =
normalized.length > SELECTION_INSERT_MAX_LENGTH
? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH).trimEnd()
: normalized
const lines = limited
.split(/\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (lines.length === 0) return
const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return
// End the blockquote with a blank line so the user's next line
// doesn't get parsed as a lazy continuation of the quote.
insertBlockContent(`${blockquote}\n\n`)
}
function insertCodeSelection(rawText: string) {
const normalized = (rawText ?? "").replace(/\r/g, "")
const limited =
normalized.length > SELECTION_INSERT_MAX_LENGTH
? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH)
: normalized
const trimmed = limited.replace(/^\n+/, "").replace(/\n+$/, "")
if (!trimmed) return
const block = "```\n" + trimmed + "\n```\n\n"
insertBlockContent(block)
}
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
const hasHistory = () => history().length > 0
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
const canHistoryGoNext = () => historyIndex() >= 0
const canSend = () => {
if (props.disabled) return false
const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText
return hasText || attachments().length > 0
}
const shellHint = () =>
mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
: { key: "!", text: t("promptInput.hints.shell.enable") }
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
const submitOnEnter = () => preferences().promptSubmitOnEnter
const handleKeyDown = usePromptKeyDown({
getTextarea: () => textareaRef ?? null,
prompt,
setPrompt,
mode,
setMode,
isPickerOpen: showPicker,
closePicker: handlePickerClose,
ignoredAtPositions,
setIgnoredAtPositions,
getAttachments: attachments,
removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId),
submitOnEnter,
onSend: () => void handleSend(),
selectPreviousHistory: (force) =>
selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }),
selectNextHistory: (force) =>
selectNextHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }),
})
const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance()
return (
<div class="prompt-input-container">
<div
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={
isDragging()
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
: ""
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
workspaceId={props.instanceId}
/>
</Show>
<div class="flex flex-1 flex-col">
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Show
when={submitOnEnter()}
fallback={
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")}
</>
}
>
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.send")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.newLine")}
</>
</Show>
{" "} <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} <Kbd></Kbd> {t("promptInput.overlay.history")}
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
{t("promptInput.overlay.press")} <Kbd>Esc</Kbd> {t("promptInput.overlay.againToAbort")}
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show>
</>
</Show>
</div>
</Show>
</div>
</div>
</div>
<div class="prompt-input-actions">
<button
type="button"
class="stop-button"
onClick={handleAbort}
disabled={!canStop()}
aria-label={t("promptInput.stopSession.ariaLabel")}
title={t("promptInput.stopSession.title")}
>
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="4" y="4" width="12" height="12" rx="2" />
</svg>
</button>
<button
type="button"
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend}
disabled={!canSend()}
aria-label={t("promptInput.send.ariaLabel")}
>
<Show
when={mode() === "shell"}
fallback={<span class="send-icon"></span>}
>
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
</svg>
</Show>
</button>
</div>
</div>
</div>
)
}