From 25f3eac9a617db55aa440df767b31d486ca4077e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Feb 2026 15:14:34 +0100 Subject: [PATCH 1/2] feat(vscode): make filenames in chat clickable to open editor tab (#461, #404) Add onOpenFile callback to shared DataProvider so tool renderers can trigger file-open in the host environment. In the VS Code extension, clicking a filename posts an openFile message to the extension host which resolves the path and opens the document in a preview tab. Tool call headers (read, edit, write, apply_patch) now show pointer cursor and underline on hover for file paths. Inline code in markdown prose that looks like a file path (relative or absolute, with optional :line or :line:col suffix) is wrapped with a file-link class and opens on click via delegated event handling. When a line number is present, the editor scrolls to that line. Also adds cursor:pointer to user-message attachments to fix the general clickable-items discoverability issue. --- packages/kilo-vscode/src/KiloProvider.ts | 27 ++++++ packages/kilo-vscode/webview-ui/src/App.tsx | 9 +- .../webview-ui/src/types/messages.ts | 8 ++ packages/ui/src/components/markdown.css | 13 +++ packages/ui/src/components/message-part.css | 40 +++++++++ packages/ui/src/components/message-part.tsx | 83 +++++++++++++++++-- packages/ui/src/context/data.tsx | 4 + packages/ui/src/context/marked.tsx | 27 ++++++ 8 files changed, 202 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 7a43b8d1bb..4a74fa38f6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -316,6 +316,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper vscode.env.openExternal(vscode.Uri.parse(message.url)) } break + case "openFile": + if (message.filePath) { + this.handleOpenFile(message.filePath, message.line, message.column) + } + break case "requestProviders": await this.fetchAndSendProviders() break @@ -1201,6 +1206,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Handle openFile request from the webview — open a file in the VS Code editor. + */ + private handleOpenFile(filePath: string, line?: number, column?: number): void { + const absolute = /^(?:\/|[a-zA-Z]:[\\/])/.test(filePath) + const uri = absolute + ? vscode.Uri.file(filePath) + : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory()), 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), + ) + } + /** * Handle logout request from the webview. */ diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index f940c36e46..c7961f25ba 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -10,7 +10,7 @@ import { DataProvider } from "@kilocode/kilo-ui/context/data" import { Toast } from "@kilocode/kilo-ui/toast" import Settings from "./components/Settings" import ProfileView from "./components/ProfileView" -import { VSCodeProvider } from "./context/vscode" +import { VSCodeProvider, useVSCode } from "./context/vscode" import { ServerProvider, useServer } from "./context/server" import { ProviderProvider } from "./context/provider" import { ConfigProvider } from "./context/config" @@ -47,6 +47,7 @@ const DummyView: Component<{ title: string }> = (props) => { */ export const DataBridge: Component<{ children: any }> = (props) => { const session = useSession() + const vscode = useVSCode() const data = createMemo(() => { const id = session.currentSessionID() @@ -70,8 +71,12 @@ export const DataBridge: Component<{ children: any }> = (props) => { session.syncSession(sessionID) } + const open = (filePath: string, line?: number, column?: number) => { + vscode.postMessage({ type: "openFile", filePath, line, column }) + } + return ( - + {props.children} ) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 9d9cef885e..429012b291 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -651,6 +651,13 @@ export interface OpenExternalRequest { url: string } +export interface OpenFileRequest { + type: "openFile" + filePath: string + line?: number + column?: number +} + export interface CancelLoginRequest { type: "cancelLogin" } @@ -825,6 +832,7 @@ export type WebviewMessage = | LogoutRequest | RefreshProfileRequest | OpenExternalRequest + | OpenFileRequest | CancelLoginRequest | SetOrganizationRequest | WebviewReadyRequest diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 68ae93bda4..1a5c709d65 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -173,6 +173,19 @@ /* border-radius: 2px; */ /* background: var(--surface-base); */ /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ + + &.file-link { + cursor: pointer; + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + transition: color 0.15s ease; + + &:hover { + color: var(--text-interactive-base); + text-decoration-style: solid; + } + } } /* Tables */ diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 44db4f9aa5..c1bf0a263b 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -34,6 +34,7 @@ overflow: hidden; background: var(--surface-weak); border: 1px solid var(--border-weak-base); + cursor: pointer; transition: border-color 0.15s ease; &:hover { @@ -274,6 +275,16 @@ [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + + &.clickable { + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + text-decoration: underline; + color: var(--text-base); + } + } } [data-slot="message-part-path"] { @@ -289,6 +300,16 @@ white-space: nowrap; direction: rtl; text-align: left; + + &.clickable { + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + text-decoration: underline; + color: var(--text-base); + } + } } [data-slot="message-part-filename"] { @@ -797,6 +818,16 @@ text-overflow: ellipsis; white-space: nowrap; flex-grow: 1; + + &.clickable { + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + text-decoration: underline; + color: var(--text-base); + } + } } [data-slot="apply-patch-deletion-count"] { @@ -833,4 +864,13 @@ flex-shrink: 0; color: var(--icon-weak); } + + &.clickable { + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--text-base); + } + } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 42dc37abc5..f50595b1a0 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -682,11 +682,26 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { setTimeout(() => setCopied(false), 2000) } + const handleMarkdownClick = (e: MouseEvent) => { + if (!data.openFile) return + const target = e.target + if (!(target instanceof HTMLElement)) return + const link = target.closest(".file-link[data-file-path]") + if (!link) return + const path = link.getAttribute("data-file-path") + if (!path) return + const lineAttr = link.getAttribute("data-file-line") + const colAttr = link.getAttribute("data-file-col") + const line = lineAttr ? parseInt(lineAttr, 10) : undefined + const column = colAttr ? parseInt(colAttr, 10) : undefined + data.openFile(path, line, column) + } + return (
- +
data.openFile!(props.input.filePath) : undefined + } /> {(filepath) => ( -
+
data.openFile?.(filepath)} + > {i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)} @@ -1106,10 +1128,16 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const data = useData() const i18n = useI18n() const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const handleFileClick = (e: MouseEvent) => { + if (!data.openFile || !props.input.filePath) return + e.stopPropagation() + data.openFile(props.input.filePath) + } return (
{i18n.t("ui.messagePart.title.edit")} - {filename()} + + {filename()} +
- {getDirectory(props.input.filePath!)} + + {getDirectory(props.input.filePath!)} +
@@ -1159,10 +1199,16 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const data = useData() const i18n = useI18n() const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const handleFileClick = (e: MouseEvent) => { + if (!data.openFile || !props.input.filePath) return + e.stopPropagation() + data.openFile(props.input.filePath) + } return (
{i18n.t("ui.messagePart.title.write")} - {filename()} + + {filename()} +
- {getDirectory(props.input.filePath!)} + + {getDirectory(props.input.filePath!)} +
@@ -1218,6 +1276,7 @@ interface ApplyPatchFile { ToolRegistry.register({ name: "apply_patch", render(props) { + const data = useData() const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1265,7 +1324,17 @@ ToolRegistry.register({ - {file.relativePath} + { + if (!data.openFile) return + e.stopPropagation() + data.openFile(file.filePath) + }} + > + {file.relativePath} + diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 137ea05656..815c2d3d4a 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -52,6 +52,8 @@ export type SessionHrefFn = (sessionID: string) => string export type SyncSessionFn = (sessionID: string) => void | Promise +export type OpenFileFn = (filePath: string, line?: number, column?: number) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -63,6 +65,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn onSyncSession?: SyncSessionFn + onOpenFile?: OpenFileFn }) => { return { get store() { @@ -77,6 +80,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, syncSession: props.onSyncSession, + openFile: props.onOpenFile, } }, }) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 1ea1999f8d..a2697892b2 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -461,6 +461,24 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise +// Matches text that looks like a file path: contains "/" and ends with a file extension, +// or starts with "./" or "../" or "/". Supports optional :line or :line:col suffix. +const FILE_PATH_RE = + /^((?:\/|\.\.?\/)?(?:[a-zA-Z0-9_@-][a-zA-Z0-9_@./-]*\/)*[a-zA-Z0-9_@.-]+\.[a-zA-Z0-9]+)(?::(\d+)(?::(\d+))?)?$/ + +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_RE.exec(text) + if (!match) return undefined + if (!match[1].includes("/")) return undefined + return { + path: match[1], + line: match[2] ? parseInt(match[2], 10) : undefined, + column: match[3] ? parseInt(match[3], 10) : undefined, + } +} + export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", init: (props: { nativeParser?: NativeMarkdownParser }) => { @@ -471,6 +489,15 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( const titleAttr = title ? ` title="${title}"` : "" return `${text}` }, + codespan({ text }) { + const file = parseFilePath(text) + if (file) { + const lineAttr = file.line ? ` data-file-line="${file.line}"` : "" + const colAttr = file.column ? ` data-file-col="${file.column}"` : "" + return `${text}` + } + return `${text}` + }, }, }, markedKatex({ From cf6997c109f9bad8345b124ef89adfd20b27ae89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Feb 2026 12:19:06 +0100 Subject: [PATCH 2/2] chore: add kilocode_change markers to PR #479 changes in packages/ui --- packages/ui/src/components/markdown.css | 2 ++ packages/ui/src/components/message-part.css | 10 ++++++- packages/ui/src/components/message-part.tsx | 30 ++++++++++++++++----- packages/ui/src/context/data.tsx | 6 ++--- packages/ui/src/context/marked.tsx | 4 +++ 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1a5c709d65..53ae7d2b5a 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -174,6 +174,7 @@ /* background: var(--surface-base); */ /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ + /* kilocode_change start */ &.file-link { cursor: pointer; text-decoration-line: underline; @@ -186,6 +187,7 @@ text-decoration-style: solid; } } + /* kilocode_change end */ } /* Tables */ diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index c1bf0a263b..c18f7a7263 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -34,7 +34,7 @@ overflow: hidden; background: var(--surface-weak); border: 1px solid var(--border-weak-base); - cursor: pointer; + cursor: pointer; /* kilocode_change */ transition: border-color 0.15s ease; &:hover { @@ -276,6 +276,7 @@ [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + /* kilocode_change start */ &.clickable { cursor: pointer; transition: color 0.15s ease; @@ -285,6 +286,7 @@ color: var(--text-base); } } + /* kilocode_change end */ } [data-slot="message-part-path"] { @@ -301,6 +303,7 @@ direction: rtl; text-align: left; + /* kilocode_change start */ &.clickable { cursor: pointer; transition: color 0.15s ease; @@ -310,6 +313,7 @@ color: var(--text-base); } } + /* kilocode_change end */ } [data-slot="message-part-filename"] { @@ -819,6 +823,7 @@ white-space: nowrap; flex-grow: 1; + /* kilocode_change start */ &.clickable { cursor: pointer; transition: color 0.15s ease; @@ -828,6 +833,7 @@ color: var(--text-base); } } + /* kilocode_change end */ } [data-slot="apply-patch-deletion-count"] { @@ -865,6 +871,7 @@ color: var(--icon-weak); } + /* kilocode_change start */ &.clickable { cursor: pointer; transition: color 0.15s ease; @@ -873,4 +880,5 @@ color: var(--text-base); } } + /* kilocode_change end */ } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index f50595b1a0..3cd29c611f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -682,6 +682,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { setTimeout(() => setCopied(false), 2000) } + // kilocode_change start const handleMarkdownClick = (e: MouseEvent) => { if (!data.openFile) return const target = e.target @@ -696,12 +697,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const column = colAttr ? parseInt(colAttr, 10) : undefined data.openFile(path, line, column) } + // kilocode_change end return (
- + {/* kilocode_change */}
data.openFile!(props.input.filePath) : undefined } + // kilocode_change end /> {(filepath) => (
data.openFile?.(filepath)} + classList={{ clickable: !!data.openFile }} // kilocode_change + onClick={() => data.openFile?.(filepath)} // kilocode_change > @@ -1128,16 +1132,18 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { - const data = useData() + const data = useData() // kilocode_change const i18n = useI18n() const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + // kilocode_change start const handleFileClick = (e: MouseEvent) => { if (!data.openFile || !props.input.filePath) return e.stopPropagation() data.openFile(props.input.filePath) } + // kilocode_change end return (
{i18n.t("ui.messagePart.title.edit")} + {/* kilocode_change start */} {filename()} + {/* kilocode_change end */}
+ {/* kilocode_change start */} {getDirectory(props.input.filePath!)} + {/* kilocode_change end */}
@@ -1199,16 +1209,18 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { - const data = useData() + const data = useData() // kilocode_change const i18n = useI18n() const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + // kilocode_change start const handleFileClick = (e: MouseEvent) => { if (!data.openFile || !props.input.filePath) return e.stopPropagation() data.openFile(props.input.filePath) } + // kilocode_change end return (
{i18n.t("ui.messagePart.title.write")} + {/* kilocode_change start */} {filename()} + {/* kilocode_change end */}
+ {/* kilocode_change start */} {getDirectory(props.input.filePath!)} + {/* kilocode_change end */}
@@ -1276,7 +1292,7 @@ interface ApplyPatchFile { ToolRegistry.register({ name: "apply_patch", render(props) { - const data = useData() + const data = useData() // kilocode_change const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1324,6 +1340,7 @@ ToolRegistry.register({ + {/* kilocode_change start */} {file.relativePath} + {/* kilocode_change end */} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 815c2d3d4a..26dcc2a759 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -52,7 +52,7 @@ export type SessionHrefFn = (sessionID: string) => string export type SyncSessionFn = (sessionID: string) => void | Promise -export type OpenFileFn = (filePath: string, line?: number, column?: number) => void +export type OpenFileFn = (filePath: string, line?: number, column?: number) => void // kilocode_change export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", @@ -65,7 +65,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn onSyncSession?: SyncSessionFn - onOpenFile?: OpenFileFn + onOpenFile?: OpenFileFn // kilocode_change }) => { return { get store() { @@ -80,7 +80,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, syncSession: props.onSyncSession, - openFile: props.onOpenFile, + openFile: props.onOpenFile, // kilocode_change } }, }) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index a2697892b2..315cf55aab 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -461,6 +461,7 @@ async function highlightCodeBlocks(html: string): Promise { export type NativeMarkdownParser = (markdown: string) => Promise +// kilocode_change start // Matches text that looks like a file path: contains "/" and ends with a file extension, // or starts with "./" or "../" or "/". Supports optional :line or :line:col suffix. const FILE_PATH_RE = @@ -478,6 +479,7 @@ function parseFilePath(text: string): { path: string; line?: number; column?: nu column: match[3] ? parseInt(match[3], 10) : undefined, } } +// kilocode_change end export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ name: "Marked", @@ -489,6 +491,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( const titleAttr = title ? ` title="${title}"` : "" return `${text}` }, + // kilocode_change start codespan({ text }) { const file = parseFilePath(text) if (file) { @@ -498,6 +501,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( } return `${text}` }, + // kilocode_change end }, }, markedKatex({