Skip to content
Merged
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/add-prompt-input-bidirectional-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Support bidirectional text in the prompt input.
17 changes: 17 additions & 0 deletions packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"

const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx")
const src = readFileSync(path, "utf8")

describe("PromptInput bidirectional text support", () => {
it("lets the textarea and visible overlay resolve text direction automatically", () => {
const overlay = src.match(/<div class="prompt-input-highlight-overlay"[\s\S]*?>/)?.[0]
const input = src.match(/<textarea[\s\S]*?\n\s*\/>/)?.[0]

expect(overlay).toContain('dir="auto"')
expect(input).toContain('class="prompt-input"')
expect(input).toContain('dir="auto"')
})
})
86 changes: 86 additions & 0 deletions packages/kilo-vscode/tests/unit/use-file-mention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/type

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

function textarea(value: string, cursor: number, dir: "ltr" | "rtl") {
const state = { cursor }
return {
value,
get selectionStart() {
return state.cursor
},
get selectionEnd() {
return state.cursor
},
matches: (selector: string) => selector === `:dir(${dir})`,
setSelectionRange: (start: number) => {
state.cursor = start
},
} as unknown as HTMLTextAreaElement
}

function key(key: "ArrowLeft" | "ArrowRight") {
const state = { prevented: 0 }
return {
state,
event: {
key,
preventDefault: () => state.prevented++,
} as unknown as KeyboardEvent,
}
}

describe("useFileMention", () => {
it("keeps previous file results visible while the next search is pending", async () => {
const posted: WebviewMessage[] = []
Expand Down Expand Up @@ -186,4 +214,62 @@ describe("useFileMention", () => {

dispose.fn?.()
})

it("keeps mention block arrow navigation aligned with left-to-right prompt direction", async () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const text = "See @src/main.ts now"
mention.addPaths(["src/main.ts"], "/repo")

const right = key("ArrowRight")
const input = textarea(text, "See ".length, "ltr")
expect(mention.handleArrowKey(right.event, input)).toBe(true)
expect(input.selectionStart).toBe("See @src/main.ts".length)
expect(right.state.prevented).toBe(1)

const left = key("ArrowLeft")
expect(mention.handleArrowKey(left.event, input)).toBe(true)
expect(input.selectionStart).toBe("See ".length)
expect(left.state.prevented).toBe(1)

dispose.fn?.()
})

it("keeps mention block arrow navigation aligned for right-to-left languages", async () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const text = "فایل @src/main.ts را ببین"
mention.addPaths(["src/main.ts"], "/repo")

const left = key("ArrowLeft")
const input = textarea(text, "فایل ".length, "rtl")
expect(mention.handleArrowKey(left.event, input)).toBe(true)
expect(input.selectionStart).toBe("فایل @src/main.ts".length)
expect(left.state.prevented).toBe(1)

const right = key("ArrowRight")
expect(mention.handleArrowKey(right.event, input)).toBe(true)
expect(input.selectionStart).toBe("فایل ".length)
expect(right.state.prevented).toBe(1)

dispose.fn?.()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true" dir="auto">
<Index each={buildHighlightSegments(text(), highlightMentions())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
Expand Down Expand Up @@ -1236,6 +1236,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-disabled={isDisabled()}
aria-describedby={props.blockedReason?.() ? blockedHelpId() : undefined}
rows={1}
dir="auto"
/>
</div>
</div>
Expand Down
11 changes: 9 additions & 2 deletions packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import {

const FILE_SEARCH_DEBOUNCE_MS = 150

const rtl = (textarea: HTMLTextAreaElement): boolean => {
if (textarea.matches(":dir(rtl)")) return true
if (textarea.matches(":dir(ltr)")) return false
return getComputedStyle(textarea).direction === "rtl"
}

interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
Expand Down Expand Up @@ -280,13 +286,14 @@ export function useFileMention(
// Only when there's no active selection
if (textarea.selectionStart !== textarea.selectionEnd) return false

const forward = e.key === (rtl(textarea) ? "ArrowLeft" : "ArrowRight")
// Check where the cursor WOULD land after the native move
const next = e.key === "ArrowRight" ? cursor + 1 : cursor - 1
const next = forward ? cursor + 1 : cursor - 1
const range = findMentionRange(textarea.value, next, mentionedPaths())
if (!range) return false

e.preventDefault()
const pos = e.key === "ArrowRight" ? range.end : range.start
const pos = forward ? range.end : range.start
textarea.setSelectionRange(pos, pos)
return true
}
Expand Down
Loading