Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chat-bidi-text.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/kilo-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
}

[data-slot="user-message-text"] {
background-color: color-mix(in srgb, var(--text-base) 7%, transparent);
border: 1px solid transparent;
transition: opacity 0.3s ease;

&[data-queued] {
Expand All @@ -408,6 +410,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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ export function UserMessageDisplay(props: {
<div data-slot="user-message-body">
{props.header}
<Show when={text()}>
<div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
<div data-slot="user-message-text" dir="auto" data-queued={props.queued ? "" : undefined}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</Show>
Expand Down
16 changes: 8 additions & 8 deletions packages/ui/src/components/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -71,7 +71,7 @@

ol {
list-style-type: decimal;
padding-left: 2.25rem;
padding-inline-start: 2.25rem; /* kilocode_change */
}

li {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ export function Markdown(
return (
<div
data-component="markdown"
dir={"auto" /* kilocode_change */}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Single dir="auto" on the outer container defeats per-block bidi resolution for mixed-language content

This commit removes markBidi and its per-block dir="auto" application entirely, replacing it with one dir="auto" on the root [data-component="markdown"] div. Per the HTML dir="auto" algorithm, an element's resolved direction is based on the first strongly-directional character found in its subtree (skipping descendants that carry their own explicit dir, e.g. the <code>/<pre> from marked.tsx). Since paragraphs, headings, blockquotes, lists, and tables no longer carry their own dir attribute, they no longer resolve direction independently — they simply inherit the single direction computed once for the whole container from whichever text happens to come first.

This directly undoes the goal described in this PR ("Persian/Arabic/Hebrew paragraphs render RTL and English paragraphs render LTR" within the same mixed-language message, per the reviewer test steps). A message with an English paragraph followed by a Persian paragraph will now render both with the same (English-first) direction, and the blockquote/list/table logical-property CSS in markdown.css (border-inline-start, padding-inline-start, text-align: start) will follow that single inherited direction rather than each block's own content — the exact failure mode the deleted markBidi comment warned about, just inverted (now under-marking instead of over-marking).

Worth reconsidering per-top-level-block dir="auto" (as markBidi did) rather than a single container-level attribute, or confirming this tradeoff was intentional for the mixed-content case.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
Expand Down
3 changes: 1 addition & 2 deletions packages/ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1167,8 +1167,7 @@ export function UserMessageDisplay(props: {
<Show when={text()}>
<>
<div data-slot="user-message-body">
<div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}>
{/* kilocode_change */}
<div data-slot="user-message-text" dir="auto" data-queued={props.queued ? "" : undefined}>{/* kilocode_change */}
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
{/* kilocode_change start */}
Expand Down
237 changes: 126 additions & 111 deletions packages/ui/src/context/marked.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,26 @@ 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 `<span dir="auto">${html}</span>`
}
// kilocode_change end

function renderMathInText(text: string): string {
let result = text

// Display math: $$...$$
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}$$`
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <pre><code data-lang="..."> immediately.
// The Markdown component calls deferredHighlight() after DOM paint.
const parser = marked.use(
{
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
// 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 <pre><code data-lang="..."> immediately.
// The Markdown component calls deferredHighlight() after DOM paint.
const parser = marked.use(
{
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
// kilocode_change start
codespan({ text }) {
const file = parseFilePath(text)
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
if (file) {
const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
return `<code class="file-link" dir="auto" data-file-path="${file.path}"${lineAttr}${colAttr}>${escaped}</code>`
}
return `<code dir="auto">${escaped}</code>`
},
code({ text, lang }) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
// 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 `<pre dir="auto"><code${attr}>${escaped}</code></pre>`
},
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
if (file) {
const lineAttr = file.line ? ` data-file-line="${file.line}"` : ""
const colAttr = file.column ? ` data-file-col="${file.column}"` : ""
return `<code class="file-link" data-file-path="${file.path}"${lineAttr}${colAttr}>${escaped}</code>`
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 `<code>${escaped}</code>`
},
code({ text, lang }) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
// 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 `<pre><code${attr}>${escaped}</code></pre>`
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 <pre><code data-lang="..."> 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<string> {
const html = await nativeParser(markdown)
const withMath = renderMathExpressions(html)
return highlightCodeBlocks(withMath)
},
}
// kilocode_change: markedShiki removed — the custom `code` renderer
// above returns plain <pre><code data-lang="..."> 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<string> {
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
Loading
Loading