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: {
{props.header} -
+
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b76289fd805..6100c7fb560 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -2554,7 +2554,9 @@ const AgentManagerContent: Component = () => { session.selectSession(s.id) }} > - {s.title || t("agentManager.session.untitled")} + + {s.title || t("agentManager.session.untitled")} + {formatRelativeDate(s.updatedAt)}
= (props) => { startRename() }} > - {title()} + + {title()} +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/WelcomeEmptyState.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/WelcomeEmptyState.tsx index a5bed638192..b3475bbb077 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/WelcomeEmptyState.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/WelcomeEmptyState.tsx @@ -41,7 +41,9 @@ export const WelcomeEmptyState: Component = (props) => { {(item) => ( )} diff --git a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx index 79e7d8b8ff5..c2258a7d5c1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/CloudSessionList.tsx @@ -169,7 +169,9 @@ const CloudSessionList: Component = (props) => { > {(s) => ( <> - {s.title} + + {s.title} + {formatRelativeDate(s.updatedAt)} )} diff --git a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx index 6d412adb77d..53c7b4654f1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx @@ -210,7 +210,9 @@ const SessionList: Component = (props) => { > {(s) => ( <> - {name(s)} + + {name(s)} + {formatRelativeDate(s.updatedAt)} {language.t("session.current")} diff --git a/packages/ui/package.json b/packages/ui/package.json index 538b2a93cca..e3dd08cfa67 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,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/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1190222956f..d246cb2302f 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -60,8 +60,8 @@ ol { margin-top: 8px; margin-bottom: 12px; - margin-left: 0; - padding-left: 32px; + margin-inline-start: 0; /* kilocode_change */ + padding-inline-start: 32px; /* kilocode_change */ list-style-position: outside; } @@ -71,7 +71,7 @@ ol { list-style-type: decimal; - padding-left: 2.25rem; + padding-inline-start: 2.25rem; /* kilocode_change */ } li { @@ -97,18 +97,18 @@ li > ol { margin-top: 0.25rem; margin-bottom: 0.25rem; - padding-left: 1rem; /* Minimal indent for nesting only */ + padding-inline-start: 1rem; /* Minimal indent for nesting only */ /* kilocode_change */ } li > ol { - padding-left: 1.75rem; + padding-inline-start: 1.75rem; /* kilocode_change */ } /* Blockquotes */ blockquote { - border-left: 2px solid var(--border-weak-base); + border-inline-start: 2px solid var(--border-weak-base); /* kilocode_change */ margin: 1.5rem 0; - padding-left: 0.5rem; + padding-inline-start: 0.5rem; /* kilocode_change */ color: var(--text-weak); font-style: normal; } @@ -255,7 +255,7 @@ /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ border-bottom: 1px solid var(--border-weaker-base); padding: 12px; - text-align: left; + text-align: start; /* kilocode_change */ vertical-align: top; } diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 822b94c02fa..a46b5fbd1e4 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -544,6 +544,7 @@ export function Markdown( return (
<>
-
- {/* kilocode_change */} +
{/* kilocode_change */}
{/* kilocode_change start */} diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index ce91a223990..050b7005264 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -29,6 +29,13 @@ const BLOCK = /^\$\$\n((?:\\[^]|[^\\])+?)\n\$\$(?:\n|$)/ const INLINE = /^\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$/ // kilocode_change end +// kilocode_change start: isolate KaTeX from the markdown root dir=auto. +function renderKatex(text: string, options: katex.KatexOptions): string { + const html = katex.renderToString(text, options) + return `${html}` +} +// kilocode_change end + function renderMathInText(text: string): string { let result = text @@ -36,10 +43,12 @@ function renderMathInText(text: string): string { const displayMathRegex = /\$\$([\s\S]*?)\$\$/g result = result.replace(displayMathRegex, (_, math) => { try { - return katex.renderToString(math, { + // kilocode_change start + return renderKatex(math, { displayMode: true, throwOnError: false, }) + // kilocode_change end } catch { return `$$${math}$$` } @@ -153,6 +162,8 @@ function replaceWithHighlighted(block: Element, html: string, sourceHash: string temp.innerHTML = html const highlighted = temp.firstElementChild if (!highlighted) return + const dir = pre.getAttribute("dir") // kilocode_change + if (dir) highlighted.setAttribute("dir", dir) // kilocode_change // Store a hash of the source code so the morphdom guard in Markdown can detect // mid-stream content changes without keeping the full source in the DOM. highlighted.setAttribute("data-source-hash", sourceHash) @@ -264,121 +275,125 @@ export async function deferredHighlight( } // kilocode_change end -export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ - name: "Marked", - init: (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: 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("") + ""
+        },
+      }
+
+      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