Merge pull request #479 from Kilo-Org/feat/clickable-filenames-461-404

feat(vscode): make filenames in chat clickable to open editor tab
This commit is contained in:
Igor Šćekić
2026-02-20 17:07:36 +01:00
committed by GitHub
8 changed files with 234 additions and 9 deletions
+27
View File
@@ -336,6 +336,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
@@ -1283,6 +1288,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.
*/
+7 -2
View File
@@ -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"
@@ -48,6 +48,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()
@@ -77,8 +78,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 (
<DataProvider data={data()} directory="" onPermissionRespond={respond} onSyncSession={sync}>
<DataProvider data={data()} directory="" onPermissionRespond={respond} onSyncSession={sync} onOpenFile={open}>
{props.children}
</DataProvider>
)
@@ -728,6 +728,13 @@ export interface OpenExternalRequest {
url: string
}
export interface OpenFileRequest {
type: "openFile"
filePath: string
line?: number
column?: number
}
export interface CancelLoginRequest {
type: "cancelLogin"
}
@@ -951,6 +958,7 @@ export type WebviewMessage =
| LogoutRequest
| RefreshProfileRequest
| OpenExternalRequest
| OpenFileRequest
| CancelLoginRequest
| SetOrganizationRequest
| WebviewReadyRequest
+15
View File
@@ -173,6 +173,21 @@
/* border-radius: 2px; */
/* 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;
text-decoration-style: dotted;
text-underline-offset: 2px;
transition: color 0.15s ease;
&:hover {
color: var(--text-interactive-base);
text-decoration-style: solid;
}
}
/* kilocode_change end */
}
/* Tables */
@@ -34,6 +34,7 @@
overflow: hidden;
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
cursor: pointer; /* kilocode_change */
transition: border-color 0.15s ease;
&:hover {
@@ -274,6 +275,18 @@
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
/* kilocode_change start */
&.clickable {
cursor: pointer;
transition: color 0.15s ease;
&:hover {
text-decoration: underline;
color: var(--text-base);
}
}
/* kilocode_change end */
}
[data-slot="message-part-path"] {
@@ -289,6 +302,18 @@
white-space: nowrap;
direction: rtl;
text-align: left;
/* kilocode_change start */
&.clickable {
cursor: pointer;
transition: color 0.15s ease;
&:hover {
text-decoration: underline;
color: var(--text-base);
}
}
/* kilocode_change end */
}
[data-slot="message-part-filename"] {
@@ -797,6 +822,18 @@
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
/* kilocode_change start */
&.clickable {
cursor: pointer;
transition: color 0.15s ease;
&:hover {
text-decoration: underline;
color: var(--text-base);
}
}
/* kilocode_change end */
}
[data-slot="apply-patch-deletion-count"] {
@@ -833,4 +870,15 @@
flex-shrink: 0;
color: var(--icon-weak);
}
/* kilocode_change start */
&.clickable {
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--text-base);
}
}
/* kilocode_change end */
}
+94 -7
View File
@@ -682,11 +682,28 @@ 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
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)
}
// kilocode_change end
return (
<Show when={throttledText()}>
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
<Markdown text={throttledText()} cacheKey={part.id} onClick={handleMarkdownClick} /> {/* kilocode_change */}
<div data-slot="text-part-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
@@ -751,10 +768,19 @@ ToolRegistry.register({
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
args,
}}
// kilocode_change start
onSubtitleClick={
data.openFile && props.input.filePath ? () => data.openFile!(props.input.filePath) : undefined
}
// kilocode_change end
/>
<For each={loaded()}>
{(filepath) => (
<div data-component="tool-loaded-file">
<div
data-component="tool-loaded-file"
classList={{ clickable: !!data.openFile }} // kilocode_change
onClick={() => data.openFile?.(filepath)} // kilocode_change
>
<Icon name="enter" size="small" />
<span>
{i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)}
@@ -1106,10 +1132,18 @@ ToolRegistry.register({
ToolRegistry.register({
name: "edit",
render(props) {
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 (
<BasicTool
{...props}
@@ -1119,11 +1153,27 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.edit")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
{/* kilocode_change start */}
<span
data-slot="message-part-title-filename"
classList={{ clickable: !!data.openFile }}
onClick={handleFileClick}
>
{filename()}
</span>
{/* kilocode_change end */}
</div>
<Show when={props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
{/* kilocode_change start */}
<span
data-slot="message-part-directory"
classList={{ clickable: !!data.openFile }}
onClick={handleFileClick}
>
{getDirectory(props.input.filePath!)}
</span>
{/* kilocode_change end */}
</div>
</Show>
</div>
@@ -1159,10 +1209,18 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
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 (
<BasicTool
{...props}
@@ -1172,11 +1230,27 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.write")}</span>
<span data-slot="message-part-title-filename">{filename()}</span>
{/* kilocode_change start */}
<span
data-slot="message-part-title-filename"
classList={{ clickable: !!data.openFile }}
onClick={handleFileClick}
>
{filename()}
</span>
{/* kilocode_change end */}
</div>
<Show when={props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
{/* kilocode_change start */}
<span
data-slot="message-part-directory"
classList={{ clickable: !!data.openFile }}
onClick={handleFileClick}
>
{getDirectory(props.input.filePath!)}
</span>
{/* kilocode_change end */}
</div>
</Show>
</div>
@@ -1218,6 +1292,7 @@ interface ApplyPatchFile {
ToolRegistry.register({
name: "apply_patch",
render(props) {
const data = useData() // kilocode_change
const i18n = useI18n()
const diffComponent = useDiffComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
@@ -1265,7 +1340,19 @@ ToolRegistry.register({
</span>
</Match>
</Switch>
<span data-slot="apply-patch-file-path">{file.relativePath}</span>
{/* kilocode_change start */}
<span
data-slot="apply-patch-file-path"
classList={{ clickable: !!data.openFile }}
onClick={(e: MouseEvent) => {
if (!data.openFile) return
e.stopPropagation()
data.openFile(file.filePath)
}}
>
{file.relativePath}
</span>
{/* kilocode_change end */}
<Show when={file.type !== "delete"}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Show>
+4
View File
@@ -52,6 +52,8 @@ export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export type OpenFileFn = (filePath: string, line?: number, column?: number) => void // kilocode_change
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 // kilocode_change
}) => {
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, // kilocode_change
}
},
})
+31
View File
@@ -461,6 +461,26 @@ async function highlightCodeBlocks(html: string): Promise<string> {
export type NativeMarkdownParser = (markdown: string) => Promise<string>
// 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 =
/^((?:\/|\.\.?\/)?(?:[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,
}
}
// kilocode_change end
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
name: "Marked",
init: (props: { nativeParser?: NativeMarkdownParser }) => {
@@ -471,6 +491,17 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
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)
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}>${text}</code>`
}
return `<code>${text}</code>`
},
// kilocode_change end
},
},
markedKatex({