diff --git a/apps/web/package.json b/apps/web/package.json
index e871ae81..17e4b324 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@effect/atom-react": "catalog:",
"@fontsource/dm-sans": "^5.2.8",
+ "@fontsource/noto-sans-arabic": "^5.2.10",
"@formkit/auto-animate": "^0.9.0",
"@lexical/react": "^0.41.0",
"@pierre/diffs": "^1.1.0-beta.16",
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 525a4210..6437d063 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -20,10 +20,14 @@ import { openInPreferredEditor } from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
+import { useSettings } from "../hooks/useSettings";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import type { DiffsHighlighter, SupportedLanguages } from "@pierre/diffs";
+import { resolveChatTypographyClassName } from "~/lib/chatTypography";
+import { cn } from "~/lib/utils";
+import { resolveTextDirection } from "~/lib/textDirection";
class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
@@ -255,7 +259,9 @@ function SuspenseShikiCodeBlock({
function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
+ const chatFontFamily = useSettings((settings) => settings.chatFontFamily);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
+ const textDirection = useMemo(() => resolveTextDirection(text), [text]);
const markdownUrlTransform = useCallback((href: string) => {
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
@@ -310,7 +316,17 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
);
return (
-
+
settings.chatFontFamily);
+ const composerDirection = resolveTextDirection(value);
+ const composerTypographyClassName = useMemo(
+ () =>
+ resolveChatTypographyClassName({
+ direction: composerDirection,
+ fontFamily: chatFontFamily,
+ variant: "composer",
+ }),
+ [chatFontFamily, composerDirection],
+ );
const terminalContextsSignature = terminalContextSignature(terminalContexts);
const terminalContextsSignatureRef = useRef(terminalContextsSignature);
const snapshotRef = useRef({
@@ -1092,8 +1106,11 @@ function ComposerPromptEditorInner({
0 ? null : (
-
+
{placeholder}
)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 35ffd234..aa2fbfdf 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -54,6 +54,7 @@ import {
type ParsedTerminalContextEntry,
} from "~/lib/terminalContext";
import { cn } from "~/lib/utils";
+import { useSettings } from "../../hooks/useSettings";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatTimestamp } from "../../timestampFormat";
import { parseReviewReport, type ReviewFinding } from "../../review";
@@ -63,6 +64,8 @@ import {
formatInlineTerminalContextLabel,
textContainsInlineTerminalContextLabels,
} from "./userMessageTerminalContexts";
+import { resolveChatTypographyClassName } from "~/lib/chatTypography";
+import { resolveTextDirection } from "~/lib/textDirection";
const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
@@ -708,6 +711,17 @@ const UserMessageBody = memo(function UserMessageBody(props: {
text: string;
terminalContexts: ParsedTerminalContextEntry[];
}) {
+ const chatFontFamily = useSettings((settings) => settings.chatFontFamily);
+ const textDirection = resolveTextDirection(props.text);
+ const bodyClassName = cn(
+ "chat-message-inline-body whitespace-pre-wrap text-sm leading-relaxed text-foreground",
+ textDirection !== "rtl" && "font-mono",
+ resolveChatTypographyClassName({
+ direction: textDirection,
+ fontFamily: chatFontFamily,
+ }),
+ );
+
if (props.terminalContexts.length > 0) {
const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
props.text,
@@ -752,7 +766,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}
return (
-
+
{inlineNodes}
);
@@ -780,7 +798,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}
return (
-
+
{inlineNodes}
);
diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx
index 55f01dc0..9b0d2d24 100644
--- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx
+++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx
@@ -1,4 +1,4 @@
-import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import { DEFAULT_UNIFIED_SETTINGS, type ChatFontFamily } from "@t3tools/contracts/settings";
import { useTheme } from "../../hooks/useTheme";
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
@@ -23,6 +23,12 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+const CHAT_FONT_LABELS = {
+ auto: "Auto",
+ "dm-sans": "DM Sans",
+ "noto-sans-arabic": "Noto Sans Arabic",
+} as const satisfies Record
;
+
export function SettingsAppearancePanel() {
const { theme, setTheme } = useTheme();
const settings = useSettings();
@@ -64,6 +70,48 @@ export function SettingsAppearancePanel() {
}
/>
+
+ updateSettings({
+ chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
void) {
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
+ ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? ["Chat font"] : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
@@ -87,6 +88,7 @@ export function useSettingsRestore(onRestored?: () => void) {
isGitWritingModelDirty,
isPromptEnhanceModelDirty,
settings.askModelSelection,
+ settings.chatFontFamily,
settings.codeModelSelection,
settings.commitMessageStyle,
settings.confirmThreadArchive,
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index dbcd3118..120731cb 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -63,9 +63,7 @@ function splitPatch(patch: SettingsUpdatePatch): {
* only re-render when the slice they care about changes.
*/
-export function useSettings(
- selector?: (s: UnifiedSettings) => T,
-): T {
+export function useSettings(selector?: (s: UnifiedSettings) => T): T {
const serverSettings = useServerSettings();
const [clientSettings] = useLocalStorage(
CLIENT_SETTINGS_STORAGE_KEY,
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 9c664b5b..159d8973 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -66,6 +66,12 @@
--desktop-window-safe-inset: 0px;
--desktop-window-corner-radius: 0px;
--radius: 0.625rem;
+ --font-sans-ui:
+ "DM Sans", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
+ sans-serif;
+ --font-sans-chat-rtl:
+ "Noto Sans Arabic", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
+ sans-serif;
--background: var(--color-white);
--foreground: var(--color-neutral-800);
--card: var(--color-white);
@@ -123,13 +129,7 @@
}
body {
- font-family:
- "DM Sans",
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- system-ui,
- sans-serif;
+ font-family: var(--font-sans-ui);
margin: 0;
padding: 0;
min-height: 100vh;
@@ -252,10 +252,43 @@ label:has(> select#reasoning-effort) select {
}
/* Chat markdown rendering */
+.chat-direction-rtl,
+.chat-composer-direction-rtl {
+ direction: rtl;
+ text-align: start;
+ font-family: var(--font-sans-chat-rtl);
+}
+
+.chat-direction-ltr,
+.chat-composer-direction-ltr {
+ direction: ltr;
+ text-align: start;
+}
+
+.chat-direction-auto,
+.chat-composer-direction-auto {
+ text-align: start;
+}
+
+.chat-font-dm-sans {
+ font-family: var(--font-sans-ui);
+}
+
+.chat-font-noto-sans-arabic {
+ font-family: var(--font-sans-chat-rtl);
+}
+
+.chat-message-inline-body {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
.chat-markdown {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
+ text-align: start;
}
.chat-markdown > :first-child {
@@ -276,12 +309,12 @@ label:has(> select#reasoning-effort) select {
}
.chat-markdown ul {
- padding-left: 1.25rem;
+ padding-inline-start: 1.25rem;
list-style-type: disc;
}
.chat-markdown ol {
- padding-left: 1.25rem;
+ padding-inline-start: 1.25rem;
list-style-type: decimal;
}
@@ -316,8 +349,8 @@ label:has(> select#reasoning-effort) select {
}
.chat-markdown blockquote {
- border-left: 2px solid var(--border);
- padding-left: 0.8rem;
+ border-inline-start: 2px solid var(--border);
+ padding-inline-start: 0.8rem;
color: var(--muted-foreground);
}
@@ -328,6 +361,7 @@ label:has(> select#reasoning-effort) select {
padding: 0.1rem 0.35rem;
color: var(--foreground);
font-size: 0.75rem;
+ direction: ltr;
}
.chat-markdown pre {
@@ -350,6 +384,7 @@ label:has(> select#reasoning-effort) select {
.chat-markdown .chat-markdown-codeblock {
position: relative;
margin: 0.65rem 0;
+ direction: ltr;
}
.chat-markdown .chat-markdown-codeblock pre {
@@ -359,7 +394,7 @@ label:has(> select#reasoning-effort) select {
.chat-markdown .chat-markdown-copy-button {
position: absolute;
top: 0.5rem;
- right: 0.5rem;
+ inset-inline-end: 0.5rem;
z-index: 1;
display: inline-flex;
align-items: center;
@@ -402,7 +437,7 @@ label:has(> select#reasoning-effort) select {
.chat-markdown td {
border: 1px solid var(--border);
padding: 0.35rem 0.45rem;
- text-align: left;
+ text-align: start;
}
/* Diffs theme bridge (match diff surfaces to app palette) */
diff --git a/apps/web/src/lib/chatTypography.ts b/apps/web/src/lib/chatTypography.ts
new file mode 100644
index 00000000..bb21ecb4
--- /dev/null
+++ b/apps/web/src/lib/chatTypography.ts
@@ -0,0 +1,35 @@
+import type { ChatFontFamily } from "@t3tools/contracts/settings";
+import type { TextDirection } from "./textDirection";
+import { cn } from "./utils";
+
+type ChatTypographyVariant = "chat" | "composer";
+
+const CHAT_DIRECTION_CLASS_BY_VARIANT = {
+ chat: {
+ rtl: "chat-direction-rtl",
+ ltr: "chat-direction-ltr",
+ auto: "chat-direction-auto",
+ },
+ composer: {
+ rtl: "chat-composer-direction-rtl",
+ ltr: "chat-composer-direction-ltr",
+ auto: "chat-composer-direction-auto",
+ },
+} as const satisfies Record>;
+
+const CHAT_FONT_CLASS_BY_FAMILY = {
+ "dm-sans": "chat-font-dm-sans",
+ "noto-sans-arabic": "chat-font-noto-sans-arabic",
+} as const satisfies Record, string>;
+
+export function resolveChatTypographyClassName(options: {
+ direction: TextDirection;
+ fontFamily: ChatFontFamily;
+ variant?: ChatTypographyVariant;
+}) {
+ const variant = options.variant ?? "chat";
+ return cn(
+ CHAT_DIRECTION_CLASS_BY_VARIANT[variant][options.direction],
+ options.fontFamily !== "auto" && CHAT_FONT_CLASS_BY_FAMILY[options.fontFamily],
+ );
+}
diff --git a/apps/web/src/lib/textDirection.test.ts b/apps/web/src/lib/textDirection.test.ts
new file mode 100644
index 00000000..c8510aaf
--- /dev/null
+++ b/apps/web/src/lib/textDirection.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+
+import { isRtlText, resolveTextDirection } from "./textDirection";
+
+describe("resolveTextDirection", () => {
+ it("detects Arabic text as rtl", () => {
+ expect(resolveTextDirection("مرحبا بك في كودو")).toBe("rtl");
+ expect(isRtlText("تحليل المشروع")).toBe(true);
+ });
+
+ it("treats Latin text as ltr", () => {
+ expect(resolveTextDirection("Review the latest diff")).toBe("ltr");
+ expect(isRtlText("Run tests")).toBe(false);
+ });
+
+ it("ignores leading punctuation before the first strong rtl character", () => {
+ expect(resolveTextDirection('... "مرحبا"')).toBe("rtl");
+ });
+
+ it("falls back to auto when no strong directional characters exist", () => {
+ expect(resolveTextDirection("1234 [] ()")).toBe("auto");
+ });
+});
diff --git a/apps/web/src/lib/textDirection.ts b/apps/web/src/lib/textDirection.ts
new file mode 100644
index 00000000..690e53c7
--- /dev/null
+++ b/apps/web/src/lib/textDirection.ts
@@ -0,0 +1,21 @@
+export type TextDirection = "ltr" | "rtl" | "auto";
+
+const RTL_CHAR_REGEX = /[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
+const LTR_CHAR_REGEX = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u052F]/;
+
+export function resolveTextDirection(text: string): TextDirection {
+ for (const character of text) {
+ if (RTL_CHAR_REGEX.test(character)) {
+ return "rtl";
+ }
+ if (LTR_CHAR_REGEX.test(character)) {
+ return "ltr";
+ }
+ }
+
+ return "auto";
+}
+
+export function isRtlText(text: string): boolean {
+ return resolveTextDirection(text) === "rtl";
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 06037b9a..fc58d31f 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -6,6 +6,9 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"
import "@fontsource/dm-sans/400.css";
import "@fontsource/dm-sans/500.css";
import "@fontsource/dm-sans/700.css";
+import "@fontsource/noto-sans-arabic/400.css";
+import "@fontsource/noto-sans-arabic/500.css";
+import "@fontsource/noto-sans-arabic/700.css";
import "@xterm/xterm/css/xterm.css";
import "./index.css";
diff --git a/bun.lock b/bun.lock
index a65e3499..e70568ae 100644
--- a/bun.lock
+++ b/bun.lock
@@ -70,6 +70,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@effect/atom-react": "catalog:",
"@fontsource/dm-sans": "^5.2.8",
+ "@fontsource/noto-sans-arabic": "^5.2.10",
"@formkit/auto-animate": "^0.9.0",
"@lexical/react": "^0.41.0",
"@pierre/diffs": "^1.1.0-beta.16",
@@ -335,6 +336,8 @@
"@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="],
+ "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.2.10", "", {}, "sha512-Hgj3NwQJ0NquIADW3hFboFjsts4UvEOJQ8tOX0bhQZgpNKHsMawjv/1SZnq0QvRbP6efHTZM/A1k7tdy9S6sbA=="],
+
"@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 90be6c79..4c1f7318 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -29,7 +29,12 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at
export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
+export const ChatFontFamily = Schema.Literals(["auto", "dm-sans", "noto-sans-arabic"]);
+export type ChatFontFamily = typeof ChatFontFamily.Type;
+export const DEFAULT_CHAT_FONT_FAMILY: ChatFontFamily = "auto";
+
export const ClientSettingsSchema = Schema.Struct({
+ chatFontFamily: ChatFontFamily.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_FAMILY)),
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),
diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),