Skip to content
Closed
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/clickable-file-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Make file references in agent responses clickable by validating inline code spans against the filesystem. Code spans that match real files in the workspace become clickable links that open the file at the referenced line. Non-existent paths stay as plain code. Also adds fallback workspace search and "File not found" warning when clicking dead links.
67 changes: 62 additions & 5 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1307,11 +1307,68 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
setTimeout(() => setCopied(false), 2000)
}

// Post-render: validate file-link candidates against the filesystem.
// Candidates that exist as files get promoted to .file-link; others stay plain code.
// Results are cached (capped at 500 entries) so morphdom re-renders resolve immediately.
let bodyRef: HTMLDivElement | undefined
const cache = new Map<string, boolean>()

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: cache is never cleared — unbounded memory growth during long sessions

The Map<string, boolean> accumulates one entry per unique candidate path encountered across the lifetime of this component instance. In a long session with many assistant messages referencing many distinct paths, this can grow large. Consider bounding the cache size (e.g. LRU with a cap of ~500 entries), or clearing it when the session changes.

const MAX_CACHE = 500

const promote = (el: HTMLElement, path: string, exists: boolean) => {
if (exists) {
// Strip ./ prefix for the click handler — VS Code resolves relative
// paths against the workspace root, so "./LICENSE" → "LICENSE".
const clean = path.startsWith("./") ? path.slice(2) : path
el.classList.remove("file-link-candidate")
el.classList.add("file-link")
el.setAttribute("data-file-path", clean)
} else {
el.classList.remove("file-link-candidate")
el.removeAttribute("data-file-candidate")
el.removeAttribute("data-file-line")
el.removeAttribute("data-file-col")
}
}

createEffect(() => {
throttledText()
if (!bodyRef) return
const pending: string[] = []
const elements = new Map<string, HTMLElement[]>()
for (const el of bodyRef.querySelectorAll<HTMLElement>("code.file-link-candidate")) {
const p = el.getAttribute("data-file-candidate") ?? ""
if (!p) continue
// If already cached, apply immediately (no round-trip)
if (cache.has(p)) {
promote(el, p, cache.get(p)!)
continue
}
if (!elements.has(p)) {
elements.set(p, [])
pending.push(p)
}
elements.get(p)!.push(el)
}
if (!pending.length || !data.validateFiles) return
data.validateFiles(pending)
.then((existing) => {
const set = new Set(existing)
for (const p of pending) {
if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value!)
cache.set(p, set.has(p))
}
for (const [p, els] of elements) {
for (const el of els) promote(el, p, set.has(p))
}
})
.catch(() => { /* validateFiles timed out or failed — candidates stay as plain code */ })
})

const handleMarkdownClick = (e: MouseEvent) => {
if (!data.openFile) return
const target = e.target
if (!(target instanceof HTMLElement)) return
// Handle .file-link code spans (e.g. `src/foo.ts:42`)
// Handle .file-link code spans (confirmed by filesystem validation)
const fileLink = target.closest(".file-link[data-file-path]")
if (fileLink) {
const path = fileLink.getAttribute("data-file-path")
Expand All @@ -1328,17 +1385,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
if (anchor) {
const href = anchor.getAttribute("href")
if (!href) return
const filePath = extractFilePathFromHref(href)
if (!filePath) return
const result = extractFilePathFromHref(href)
if (!result) return
e.preventDefault()
data.openFile(filePath)
data.openFile(result.path, result.line, result.column)
}
}

return (
<Show when={throttledText() && showSyntheticPart()}>
<div data-component="text-part">
<div data-slot="text-part-body">
<div data-slot="text-part-body" ref={bodyRef}>
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
</div>
<Show when={showCopy()}>
Expand Down
73 changes: 39 additions & 34 deletions packages/kilo-ui/src/file-path.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,60 @@
// Matches text that looks like a file path:
// - Unix: /foo/bar.ts, ./foo.ts, ../foo.ts, foo.ts
// - Windows drive: C:\foo\bar.ts, C:/foo/bar.ts
// - Windows UNC: \\server\share\file.ts
// Supports optional :line or :line:col suffix.
const FILE_PATH_UNIX_RE =
/^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/
const FILE_PATH_WIN_RE = /^((?:[a-zA-Z]:[/\\]|\\\\)(?:[^\\/]+[/\\])*[^\\/]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/
/**
* Strip an optional :line[-endline][:col] suffix from a code span.
* Returns the candidate file path and optional line/column numbers.
*/
export function extractSuffix(text: string): { candidate: string; line?: number; column?: number } {
// Try :line:col first, then :line (with optional -endline range)
const m3 = /^(.+):(\d+)(?:-\d+)?:(\d+)$/.exec(text)
if (m3) return { candidate: m3[1], line: +m3[2], column: +m3[3] }
const m2 = /^(.+):(\d+)(?:-\d+)?$/.exec(text)
if (m2) return { candidate: m2[1], line: +m2[2] }
return { candidate: text }
}

/**
* Parse an inline code span into a file path with optional line/column.
* Returns undefined when the text does not look like a file reference.
*
* Handles Unix paths (`/foo/bar.ts`, `./foo.ts`, `foo.ts`),
* Windows drive paths (`C:\foo\bar.ts`), and UNC paths (`\\server\share\file.ts`).
* Normalize a candidate path for filesystem validation.
* Ensures the path has a ./ prefix if it's a bare relative path,
* so the extension can stat-check it against the workspace root.
*/
export function parseFilePath(text: string): { path: string; line?: number; column?: number } | undefined {
if (text.includes("://")) return undefined
if (text.includes(" ")) return undefined
const match = FILE_PATH_UNIX_RE.exec(text) ?? FILE_PATH_WIN_RE.exec(text)
if (!match) return undefined
return {
path: match[1],
line: match[2] ? parseInt(match[2], 10) : undefined,
column: match[3] ? parseInt(match[3], 10) : undefined,
}
export function normalizeCandidatePath(path: string): string {
if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return path
// Windows absolute paths (C:\...) — leave as-is
if (/^[a-zA-Z]:[/\\]/.test(path)) return path
// Windows UNC paths (\\server\...) — leave as-is
if (path.startsWith("\\\\")) return path
// Strip a/b diff prefixes
const stripped = path.replace(/^[ab]\//, "")
return `./${stripped}`
}

const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
// Matches a URI scheme but NOT a Windows drive letter (single char followed by colon).
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]+:/

/**
* Extract a file path from a markdown link href, or return undefined
* when the href is a URL, anchor, scheme, or otherwise not a file reference.
* Extract a file path (with optional line/column) from a markdown link href,
* or return undefined when the href is a URL, anchor, scheme, or otherwise
* not a file reference.
*
* Strips `#fragment` and `?query` suffixes before returning the path.
* Strips `#fragment` and `?query` suffixes, then parses an optional
* `:line` or `:line:column` suffix from the remaining path.
*/
export function extractFilePathFromHref(href: string): string | undefined {
export function extractFilePathFromHref(
href: string,
): { path: string; line?: number; column?: number } | undefined {
if (!href) return undefined
// Handle file:// URLs — extract the path component and decode it
if (href.startsWith("file://")) {
try {
const url = new URL(href)
const decoded = decodeURIComponent(url.pathname)
if (!decoded) return undefined
// On Windows, file:///C:/foo gives pathname=/C:/foo — strip the leading slash
// so the result is a valid Windows absolute path (C:/foo).
const c1 = decoded.charCodeAt(1)
const isWindowsDrive =
decoded.length >= 4 &&
decoded.charCodeAt(0) === 47 /* / */ &&
decoded.charCodeAt(2) === 58 /* : */ &&
((c1 >= 65 && c1 <= 90) /* A-Z */ || (c1 >= 97 && c1 <= 122)) /* a-z */
return isWindowsDrive ? decoded.slice(1) : decoded
return { path: isWindowsDrive ? decoded.slice(1) : decoded }
} catch {
return undefined
}
Expand All @@ -62,7 +66,8 @@ export function extractFilePathFromHref(href: string): string | undefined {
// Strip fragment and query before treating as file path
const cleaned = href.replace(/[#?].*$/, "")
if (!cleaned) return undefined
// Must look like a file path (has a dot for extension)
if (!cleaned.includes(".")) return undefined
return cleaned
// Strip a/b diff prefixes, parse :line[:col] suffix
const stripped = cleaned.replace(/^[ab]\//, "")
const { candidate, line, column } = extractSuffix(stripped)
return { path: candidate, line, column }
}
37 changes: 18 additions & 19 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as path from "path"
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview"
import { isAbsolutePath } from "./path-utils"
import type {
KiloClient,
Session,
Expand Down Expand Up @@ -81,6 +80,7 @@ import { openConfig } from "./kilo-provider/open-config"
import * as McpOAuth from "./kilo-provider/mcp-oauth"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
import { hasGit } from "./kilo-provider/git-status"
import * as fileLinks from "./kilo-provider/file-links"
// legacy-migration start
import {
checkAndShowMigrationWizard,
Expand Down Expand Up @@ -2973,9 +2973,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (message.content) this.handleOpenContent(message.content, message.language)
return true
}
if (message.type === "validateFiles") {
const msg = message as { id: string; paths: string[] }
this.handleValidateFiles(msg.id, msg.paths)
return true
}
return false
}

/** Stat-check candidate paths and respond with which ones are real files. */
private handleValidateFiles(id: string, paths: string[]): void {
const root = this.getWorkspaceDirectory(this.currentSession?.id)
fileLinks
.validateFiles(root, paths)
.then((existing) => this.postMessage({ type: "validateFilesResult", id, existing }))

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: Missing .catch() on handleValidateFiles — unhandled promise rejection risk

If fileLinks.validateFiles() rejects unexpectedly (e.g. an unhandled vscode API error), or if this.postMessage() throws, the rejection is silently lost. The webview's validateFiles call will then time out after 3 seconds and resolve to [], which is recoverable — but the silent failure makes debugging harder and causes unnecessary 3-second delays on every affected render during that error condition.

Add a .catch() to log the failure:

fileLinks
  .validateFiles(root, paths)
  .then((existing) => this.postMessage({ type: "validateFilesResult", id, existing }))
  .catch((err) => console.error("[Kilo New] handleValidateFiles failed:", err))

.catch((err) => console.error("[Kilo New] handleValidateFiles failed:", err))
}

/**
* Handle openContent request - open arbitrary text in an untitled VS Code editor tab.
*/
Expand All @@ -2988,26 +3002,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

/**
* Handle openFile request from the webview — open a file in the VS Code editor.
* Resolves relative paths against the current session's directory (which may be
* a worktree path registered via setSessionDirectory), falling back to workspace root.
* Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is.
* Delegates to file-links.ts which resolves paths, falls back to workspace search,
* and shows a warning when the file is not found.
*/
private handleOpenFile(filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath)
? vscode.Uri.file(filePath)
: vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath)
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
fileLinks.openFile(this.getWorkspaceDirectory(this.currentSession?.id), filePath, line, column)
}

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/file-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as vscode from "vscode"
import { isAbsolutePath } from "../path-utils"

/**
* Stat-check candidate paths and return which ones are actual files (not directories).
*/
export function validateFiles(root: string, paths: string[]): Promise<string[]> {
const resolve = (p: string) =>
isAbsolutePath(p) ? vscode.Uri.file(p) : vscode.Uri.joinPath(vscode.Uri.file(root), p)
return Promise.all(
paths.map((p) =>
vscode.workspace.fs.stat(resolve(p)).then(
(s) => (s.type & vscode.FileType.File ? p : null),
() => null,
),
),
).then((r) => r.filter((x): x is string => x !== null))
}

/**
* Open a file in the editor with optional line/column positioning.
* Falls back to a workspace-wide filename search if the exact path doesn't exist.
*/
export function openFile(root: string, filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath)
? vscode.Uri.file(filePath)
: vscode.Uri.joinPath(vscode.Uri.file(root), filePath)
const opts: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const pos = new vscode.Position(line - 1, column !== undefined && column > 0 ? column - 1 : 0)
opts.selection = new vscode.Range(pos, pos)
}
const show = (target: vscode.Uri) =>
vscode.workspace.openTextDocument(target).then(
(doc) => vscode.window.showTextDocument(doc, opts),
(err) => console.error("[Kilo New] openFile show failed:", err),
)
vscode.workspace.fs.stat(uri).then(
() => show(uri),
() => {
const name = filePath.split(/[\\/]/).pop() || filePath
Promise.resolve(vscode.workspace.findFiles(`**/${name}`, "**/node_modules/**", 5))
.then((matches) => {
if (matches.length === 1) {
show(matches[0])
return
}
if (matches.length > 1) {
const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m }))
vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then((p) => {
if (p) show(p.uri)
})
return
}
vscode.window.showWarningMessage(`File not found: ${filePath}`)
})
.catch((err: unknown) => console.error("[Kilo New] findFiles failed:", err))
},
)
}
30 changes: 30 additions & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,35 @@ export const DataBridge: Component<{ children: any }> = (props) => {
vscode.postMessage({ type: "openContent", content, language })
}

// File existence validation for code span candidates
const pending = new Map<string, (existing: string[]) => void>()
const counter = { n: 0 }
const validateFiles = (paths: string[]): Promise<string[]> => {
const id = `vf-${++counter.n}`
return new Promise((resolve) => {
pending.set(id, resolve)
vscode.postMessage({ type: "validateFiles", id, paths })
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id)
resolve([])
}
}, 3000)
})
}
const handler = (event: MessageEvent) => {
const msg = event.data
if (msg?.type === "validateFilesResult" && msg.id) {
const cb = pending.get(msg.id)
if (cb) {
pending.delete(msg.id)
cb(msg.existing ?? [])
}
}
}
onMount(() => window.addEventListener("message", handler))
onCleanup(() => window.removeEventListener("message", handler))

const directory = () => {
const dir = server.workspaceDirectory()
if (!dir) return ""
Expand All @@ -166,6 +195,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
onValidateFiles={validateFiles}
>
{props.children}
</DataProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,12 @@ export interface RemoteStatusMessage {
connected: boolean
}

export interface ValidateFilesResultMessage {
type: "validateFilesResult"
id: string
existing: string[]
}

export type ExtensionMessage =
| ReadyMessage
| FontSizeChangedMessage
Expand Down Expand Up @@ -1061,3 +1067,4 @@ export type ExtensionMessage =
| ExtensionDataReadyMessage
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
Loading
Loading