diff --git a/.changeset/chat-bidi-text.md b/.changeset/chat-bidi-text.md new file mode 100644 index 00000000000..7f984c6eeaf --- /dev/null +++ b/.changeset/chat-bidi-text.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support bidirectional text in chat messages so right-to-left and mixed-language conversations render in the correct direction. diff --git a/bun.lock b/bun.lock index b88b75af464..02c47ad3a71 100644 --- a/bun.lock +++ b/bun.lock @@ -712,6 +712,7 @@ "@types/bun": "catalog:", "@types/katex": "0.16.7", "@typescript/native-preview": "catalog:", + "happy-dom": "20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 5a3fd732da0..30bcfcbf85f 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -392,6 +392,9 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } [data-slot="user-message-text"] { + unicode-bidi: plaintext; /* Match the prompt input */ + background-color: color-mix(in srgb, var(--text-base) 7%, transparent); + border: 1px solid transparent; transition: opacity 0.3s ease; &[data-queued] { @@ -408,6 +411,11 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } } +body.vscode-high-contrast [data-component="user-message"] [data-slot="user-message-text"], +body.vscode-high-contrast-light [data-component="user-message"] [data-slot="user-message-text"] { + border-color: var(--border-weak-base); +} + [data-component="tool-output"] { margin-bottom: 0px; } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 7fca6106088..2ffb6a4f428 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -849,7 +849,7 @@ export function UserMessageDisplay(props: {
immediately.
- // The Markdown component calls deferredHighlight() after DOM paint.
- const parser = marked.use(
- {
- renderer: {
- link({ href, title, text }) {
- const titleAttr = title ? ` title="${title}"` : ""
- return `${text}`
+// kilocode_change start: expose the parser setup for Kilo markdown tests.
+export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser }) => {
+ // kilocode_change start: two-pass parser — first pass skips Shiki highlighting
+ // to avoid blocking the main thread with Oniguruma WASM regex (issue #6221).
+ // Code blocks render as plain immediately.
+ // The Markdown component calls deferredHighlight() after DOM paint.
+ const parser = marked.use(
+ {
+ renderer: {
+ link({ href, title, text }) {
+ const titleAttr = title ? ` title="${title}"` : ""
+ return `${text}`
+ },
+ // kilocode_change start
+ codespan({ text }) {
+ const file = parseFilePath(text)
+ const escaped = text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ if (file) {
+ const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
+ const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
+ return `${escaped}`
+ }
+ return `${escaped}`
+ },
+ code({ text, lang }) {
+ const escaped = text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ // Normalize aliases (e.g. "c++" → "cpp") before stripping special
+ // chars, so "c++" doesn't become "c" (wrong language highlight).
+ const normalized = lang ? (LANG_ALIASES[lang] ?? lang) : ""
+ const safe = normalized ? normalized.replace(/[^a-zA-Z0-9_-]/g, "") : ""
+ const data = safe.toLowerCase() === "mermaid" ? "mermaid" : safe
+ const attr = data ? ` class="language-${data}" data-lang="${data}"` : ' data-lang="text"'
+ return `${escaped}
`
+ },
+ // kilocode_change end
+ },
+ },
+ // kilocode_change start: enable only double-dollar math.
+ // Single $ is far more common as a currency symbol in agent responses
+ // (e.g. $93K, $307K) than as a LaTeX delimiter. Avoid registering the
+ // marked-katex-extension inline tokenizer because Marked falls through
+ // to later tokenizers when an override returns undefined.
+ {
+ extensions: [
+ {
+ name: "doubleKatexBlock",
+ level: "block" as const,
+ tokenizer(src) {
+ const match = src.match(BLOCK)
+ const text = match?.[1]
+ if (!match || !text) return undefined
+ return {
+ type: "doubleKatexBlock",
+ raw: match[0],
+ text: text.trim(),
+ }
+ },
+ renderer(token) {
+ return `${renderKatex(token.text, { displayMode: true, throwOnError: false })}\n`
+ },
+ } satisfies TokenizerAndRendererExtension,
+ {
+ name: "doubleKatexInline",
+ level: "inline" as const,
+ start(src) {
+ const index = src.indexOf("$$")
+ if (index === -1) return undefined
+ return index
},
- // kilocode_change start
- codespan({ text }) {
- const file = parseFilePath(text)
- const escaped = text
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'")
- if (file) {
- const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
- const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
- return `${escaped}`
+ tokenizer(src) {
+ const match = src.match(INLINE)
+ const text = match?.[1]
+ if (!match || !text) return undefined
+ return {
+ type: "doubleKatexInline",
+ raw: match[0],
+ text: text.trim(),
}
- return `${escaped}`
},
- code({ text, lang }) {
- const escaped = text
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'")
- // Normalize aliases (e.g. "c++" → "cpp") before stripping special
- // chars, so "c++" doesn't become "c" (wrong language highlight).
- const normalized = lang ? (LANG_ALIASES[lang] ?? lang) : ""
- const safe = normalized ? normalized.replace(/[^a-zA-Z0-9_-]/g, "") : ""
- const data = safe.toLowerCase() === "mermaid" ? "mermaid" : safe
- const attr = data ? ` class="language-${data}" data-lang="${data}"` : ' data-lang="text"'
- return `${escaped}
`
+ renderer(token) {
+ return renderKatex(token.text, { displayMode: true, throwOnError: false })
},
- // kilocode_change end
- },
- },
- // kilocode_change start: enable only double-dollar math.
- // Single $ is far more common as a currency symbol in agent responses
- // (e.g. $93K, $307K) than as a LaTeX delimiter. Avoid registering the
- // marked-katex-extension inline tokenizer because Marked falls through
- // to later tokenizers when an override returns undefined.
- {
- extensions: [
- {
- name: "doubleKatexBlock",
- level: "block" as const,
- tokenizer(src) {
- const match = src.match(BLOCK)
- const text = match?.[1]
- if (!match || !text) return undefined
- return {
- type: "doubleKatexBlock",
- raw: match[0],
- text: text.trim(),
- }
- },
- renderer(token) {
- return `${katex.renderToString(token.text, { displayMode: true, throwOnError: false })}\n`
- },
- } satisfies TokenizerAndRendererExtension,
- {
- name: "doubleKatexInline",
- level: "inline" as const,
- start(src) {
- const index = src.indexOf("$$")
- if (index === -1) return undefined
- return index
- },
- tokenizer(src) {
- const match = src.match(INLINE)
- const text = match?.[1]
- if (!match || !text) return undefined
- return {
- type: "doubleKatexInline",
- raw: match[0],
- text: text.trim(),
- }
- },
- renderer(token) {
- return katex.renderToString(token.text, { displayMode: true, throwOnError: false })
- },
- } satisfies TokenizerAndRendererExtension,
- ],
- } satisfies MarkedExtension,
- // kilocode_change end
- // kilocode_change: markedShiki removed — the custom `code` renderer
- // above returns plain and markdown.tsx
- // calls deferredHighlight() after paint. Running Shiki inside parse
- // blocks the main thread on session switches (issue #6221).
- )
+ } satisfies TokenizerAndRendererExtension,
+ ],
+ } satisfies MarkedExtension,
// kilocode_change end
-
- if (props.nativeParser) {
- const nativeParser = props.nativeParser
- return {
- async parse(markdown: string): Promise {
- const html = await nativeParser(markdown)
- const withMath = renderMathExpressions(html)
- return highlightCodeBlocks(withMath)
- },
- }
+ // kilocode_change: markedShiki removed — the custom `code` renderer
+ // above returns plain and markdown.tsx
+ // calls deferredHighlight() after paint. Running Shiki inside parse
+ // blocks the main thread on session switches (issue #6221).
+ )
+ // kilocode_change end
+
+ if (props.nativeParser) {
+ const nativeParser = props.nativeParser
+ return {
+ async parse(markdown: string): Promise {
+ const html = await nativeParser(markdown)
+ const withMath = renderMathExpressions(html)
+ return highlightCodeBlocks(withMath)
+ },
}
+ }
+
+ return parser
+}
- return parser
- },
+export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
+ name: "Marked",
+ init: createMarkedParser,
})
+// kilocode_change end
diff --git a/packages/ui/src/kilocode/markdown-bidi.test.ts b/packages/ui/src/kilocode/markdown-bidi.test.ts
new file mode 100644
index 00000000000..4382f41e6c8
--- /dev/null
+++ b/packages/ui/src/kilocode/markdown-bidi.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, test } from "bun:test"
+import { Window } from "happy-dom"
+import path from "node:path"
+import { createMarkedParser } from "../context/marked"
+import { fnv1a } from "../context/marked"
+import { update } from "./markdown-stream-highlight"
+
+const root = path.resolve(import.meta.dir, "../..")
+
+describe("Markdown bidirectional rendering contract", () => {
+ test("renders the markdown root with automatic direction", () => {
+ const code = String.raw`
+ import { mock } from "bun:test"
+ import { createComponent, renderToString } from "solid-js/web"
+
+ function attr(props) {
+ return Object.entries(props || {})
+ .filter(([key, value]) => key !== "children" && value != null && value !== false && typeof value !== "object")
+ .map(([key, value]) => " " + (key === "className" ? "class" : key) + "=\"" + String(value) + "\"")
+ .join("")
+ }
+
+ globalThis.React = {
+ createElement(type, props, ...children) {
+ const next = { ...(props || {}) }
+ if (children.length) next.children = children.length === 1 ? children[0] : children
+ if (typeof type === "function") return createComponent(type, next)
+ return "<" + type + attr(next) + ">" + children.join("") + "" + type + ">"
+ },
+ }
+
+ mock.module("./src/context/marked", () => ({
+ useMarked: () => ({ parse: async () => "" }),
+ deferredHighlight: async () => {},
+ fnv1a: (text) => text,
+ }))
+ mock.module("./src/kilocode/markdown-mermaid", () => ({
+ hasMermaid: () => false,
+ preserveMermaid: () => false,
+ renderMermaid: async () => {},
+ }))
+
+ const { Markdown } = await import("./src/components/markdown")
+ console.log(renderToString(() => createComponent(Markdown, { text: "hello" })))
+ `
+ const proc = Bun.spawnSync({
+ cmd: ["bun", "-e", code],
+ cwd: root,
+ stdout: "pipe",
+ stderr: "pipe",
+ })
+
+ expect(proc.exitCode, proc.stderr.toString()).toBe(0)
+ const html = proc.stdout.toString()
+ expect(html).toContain('data-component="markdown"')
+ expect(html).toContain('dir="auto"')
+ })
+
+ test("renders code, pre, and math with isolated direction", async () => {
+ const parser = createMarkedParser({})
+ const html = await Promise.resolve(
+ parser.parse(
+ ["متن با `inlineCode`", "", "```ts", "const value = 1", "```", "", "$$", "a = b + c", "$$"].join("\n"),
+ ),
+ )
+
+ expect(html).toContain('inlineCode')
+ expect(html).toContain('')
+ expect(html).toContain("const value = 1")
+ expect(html.match(/ {
+ const win = new Window()
+ const scope = globalThis as typeof globalThis & {
+ document: Document
+ HTMLPreElement: typeof HTMLPreElement
+ }
+ const doc = scope.document
+ const elem = scope.HTMLPreElement
+
+ try {
+ scope.document = win.document as unknown as Document
+ scope.HTMLPreElement = win.HTMLPreElement as unknown as typeof HTMLPreElement
+
+ const pre = document.createElement("pre")
+ const code = "const value = 1"
+ pre.setAttribute("dir", "auto")
+ pre.setAttribute("data-old", "removed")
+ pre.scrollLeft = 24
+ pre.innerHTML = `${code}`
+ document.body.append(pre)
+
+ update(pre, `${code}
`, code)
+
+ expect(pre.getAttribute("dir")).toBe("auto")
+ expect(pre.className).toBe("shiki")
+ expect(pre.getAttribute("tabindex")).toBe("0")
+ expect(pre.hasAttribute("data-old")).toBe(false)
+ expect(pre.getAttribute("data-source-hash")).toBe(fnv1a(code))
+ expect(pre.textContent).toBe(code)
+ expect(pre.scrollLeft).toBe(24)
+ } finally {
+ scope.document = doc
+ scope.HTMLPreElement = elem
+ }
+ })
+})
diff --git a/packages/ui/src/kilocode/markdown-stream-highlight.ts b/packages/ui/src/kilocode/markdown-stream-highlight.ts
index 993199a0933..2021fca95f8 100644
--- a/packages/ui/src/kilocode/markdown-stream-highlight.ts
+++ b/packages/ui/src/kilocode/markdown-stream-highlight.ts
@@ -29,8 +29,9 @@ async function source(lang: string, code: string) {
}
}
-function update(pre: HTMLPreElement, html: string, code: string) {
+export function update(pre: HTMLPreElement, html: string, code: string) {
if (!pre.isConnected) return
+ const dir = pre.getAttribute("dir") ?? "auto"
const temp = document.createElement("div")
temp.innerHTML = html
const next = temp.firstElementChild
@@ -42,6 +43,7 @@ function update(pre: HTMLPreElement, html: string, code: string) {
for (const attr of next.attributes) {
pre.setAttribute(attr.name, attr.value)
}
+ pre.setAttribute("dir", dir)
pre.setAttribute("data-source-hash", fnv1a(code))
pre.replaceChildren(...Array.from(next.childNodes))
pre.scrollLeft = x