diff --git a/bun.lock b/bun.lock
index 9fb62d4cd..5361cba8f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -216,6 +216,7 @@
},
"peerDependencies": {
"@opencode-ai/ui": "workspace:*",
+ "@opencode-ai/util": "workspace:*",
"solid-js": "catalog:",
},
},
diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json
index be3e00bad..62f3e18c6 100644
--- a/packages/kilo-ui/package.json
+++ b/packages/kilo-ui/package.json
@@ -78,6 +78,7 @@
},
"peerDependencies": {
"@opencode-ai/ui": "workspace:*",
+ "@opencode-ai/util": "workspace:*",
"solid-js": "catalog:"
},
"devDependencies": {
diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css
index 7aa79677b..b7289bd2d 100644
--- a/packages/kilo-ui/src/components/message-part.css
+++ b/packages/kilo-ui/src/components/message-part.css
@@ -5,4 +5,68 @@
[data-slot="text-part-body"] {
margin-top: 0;
}
+
+ &[data-has-open-file] [data-slot="text-part-body"] code:not(pre code) {
+ cursor: pointer;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: var(--text-strong);
+ }
+ }
+}
+
+[data-slot="message-part-title-filename"] {
+ &.clickable {
+ cursor: pointer;
+ text-decoration: underline;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: var(--text-strong);
+ }
+ }
+}
+
+[data-component="tool-loaded-file"] {
+ .clickable {
+ cursor: pointer;
+ text-decoration: underline;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: var(--text-base);
+ }
+ }
+}
+
+[data-component="clickable-file-output"] {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 0;
+
+ [data-slot="file-output-line"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+ color: var(--text-weak);
+ white-space: pre;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.clickable {
+ cursor: pointer;
+ text-decoration: underline;
+ text-decoration-color: transparent;
+ transition:
+ color 0.15s ease,
+ text-decoration-color 0.15s ease;
+
+ &:hover {
+ color: var(--text-base);
+ text-decoration-color: var(--text-base);
+ }
+ }
+ }
}
diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx
index a7dedd50c..55fa513c6 100644
--- a/packages/kilo-ui/src/components/message-part.tsx
+++ b/packages/kilo-ui/src/components/message-part.tsx
@@ -1,2 +1,390 @@
// kilocode_change - new file
export * from "@opencode-ai/ui/message-part"
+
+import {
+ createMemo,
+ createSignal,
+ For,
+ Show,
+} from "solid-js"
+import { Dynamic } from "solid-js/web"
+import type { TextPart } from "@kilocode/sdk/v2"
+import {
+ ToolRegistry,
+ PART_MAPPING,
+ DiagnosticsDisplay,
+ getDiagnostics,
+ relativizeProjectPaths,
+ getDirectory,
+ createThrottledValue,
+} from "@opencode-ai/ui/message-part"
+import { BasicTool } from "@opencode-ai/ui/basic-tool"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Markdown } from "@opencode-ai/ui/markdown"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { useData } from "@opencode-ai/ui/context/data"
+import { useI18n } from "@opencode-ai/ui/context/i18n"
+import { useDiffComponent } from "@opencode-ai/ui/context/diff"
+import { useCodeComponent } from "@opencode-ai/ui/context/code"
+import { getFilename } from "@opencode-ai/util/path"
+import { checksum } from "@opencode-ai/util/encode"
+import type { OpenFileFn } from "@opencode-ai/ui/context/data"
+
+/** Check if text looks like a file path (contains / and has a file extension) */
+const FILE_PATH_RE = /^\.{0,2}\/.*\.\w+$|^[a-zA-Z]:\\.*\.\w+$|^\w[\w.-]*\/.*\.\w+$/
+
+function isFilePath(text: string): boolean {
+ return FILE_PATH_RE.test(text.trim())
+}
+
+/**
+ * Renders tool output text with file paths as clickable elements.
+ * Lines that look like file paths become clickable to open the file in the editor.
+ */
+function ClickableFileOutput(props: { text: string; openFile?: OpenFileFn; directory?: string }) {
+ const lines = createMemo(() => props.text.split("\n").filter((l) => l.trim()))
+ const hasClickable = () => !!props.openFile
+
+ return (
+
+
+ {(line) => {
+ const trimmed = line.trim()
+ const displayPath = props.directory ? relativizeProjectPaths(trimmed, props.directory) : trimmed
+ return (
+ {displayPath}
}
+ >
+ props.openFile!(trimmed)}
+ >
+ {displayPath}
+
+
+ )
+ }}
+
+
+ )
+}
+
+// Override TextPartDisplay to make inline code file paths clickable
+PART_MAPPING["text"] = function TextPartDisplay(props) {
+ const data = useData()
+ const i18n = useI18n()
+ const part = props.part as TextPart
+ const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
+ const throttledText = createThrottledValue(displayText)
+ const [copied, setCopied] = createSignal(false)
+
+ const handleCopy = async () => {
+ const content = displayText()
+ if (!content) return
+ await navigator.clipboard.writeText(content)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ const handleCodeClick = (e: MouseEvent) => {
+ if (!data.openFile) return
+ const target = e.target as HTMLElement
+ const code = target.closest("code")
+ if (!code || code.closest("pre")) return
+ const text = code.textContent?.trim()
+ if (!text || !isFilePath(text)) return
+ e.preventDefault()
+ e.stopPropagation()
+ data.openFile(text)
+ }
+
+ return (
+
+
+
+
+
+
+ e.preventDefault()}
+ onClick={handleCopy}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ />
+
+
+
+
+
+ )
+}
+
+// Override read tool to add clickable subtitle and loaded file paths
+ToolRegistry.register({
+ name: "read",
+ render(props) {
+ const data = useData()
+ const i18n = useI18n()
+ const args: string[] = []
+ if (props.input.offset) args.push("offset=" + props.input.offset)
+ if (props.input.limit) args.push("limit=" + props.input.limit)
+ const loaded = createMemo(() => {
+ if (props.status !== "completed") return []
+ const value = props.metadata.loaded
+ if (!value || !Array.isArray(value)) return []
+ return value.filter((p): p is string => typeof p === "string")
+ })
+ const openFile = () => {
+ if (props.input.filePath && data.openFile) data.openFile(props.input.filePath)
+ }
+ return (
+ <>
+
+
+ {(filepath) => (
+
+
+ {
+ if (data.openFile) {
+ e.stopPropagation()
+ data.openFile(filepath)
+ }
+ }}
+ >
+ {i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)}
+
+
+ )}
+
+ >
+ )
+ },
+})
+
+// Override list tool to use clickable file output
+ToolRegistry.register({
+ name: "list",
+ render(props) {
+ const data = useData()
+ const i18n = useI18n()
+ return (
+
+
+ {(output) => (
+
+
+
+ )}
+
+
+ )
+ },
+})
+
+// Override glob tool to use clickable file output
+ToolRegistry.register({
+ name: "glob",
+ render(props) {
+ const data = useData()
+ const i18n = useI18n()
+ return (
+
+
+ {(output) => (
+
+
+
+ )}
+
+
+ )
+ },
+})
+
+// Override grep tool to use clickable file output
+ToolRegistry.register({
+ name: "grep",
+ render(props) {
+ const data = useData()
+ const i18n = useI18n()
+ const args: string[] = []
+ if (props.input.pattern) args.push("pattern=" + props.input.pattern)
+ if (props.input.include) args.push("include=" + props.input.include)
+ return (
+
+
+ {(output) => (
+
+
+
+ )}
+
+
+ )
+ },
+})
+
+// Override edit tool to add clickable filename
+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 openFile = (e: MouseEvent) => {
+ e.stopPropagation()
+ if (props.input.filePath && data.openFile) data.openFile(props.input.filePath)
+ }
+ return (
+
+
+
+ {i18n.t("ui.messagePart.title.edit")}
+
+ {filename()}
+
+
+
+
+ {getDirectory(props.input.filePath!)}
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ )
+ },
+})
+
+// Override write tool to add clickable filename
+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 openFile = (e: MouseEvent) => {
+ e.stopPropagation()
+ if (props.input.filePath && data.openFile) data.openFile(props.input.filePath)
+ }
+ return (
+
+
+
+ {i18n.t("ui.messagePart.title.write")}
+
+ {filename()}
+
+
+
+
+ {getDirectory(props.input.filePath!)}
+
+
+
+ {/* placeholder */}
+
+ }
+ >
+
+
+
+
+
+
+
+ )
+ },
+})
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index a5dabc9d9..24bdfa6bc 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -316,6 +316,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
+ case "openFile":
+ if (message.path) {
+ const uri = message.path.startsWith("/")
+ ? vscode.Uri.file(message.path)
+ : vscode.Uri.joinPath(vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file(""), message.path)
+ await vscode.window.showTextDocument(uri)
+ }
+ break
case "requestProviders":
await this.fetchAndSendProviders()
break
diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx
index f940c36e4..8edda524b 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 openFile = (path: string) => {
+ vscode.postMessage({ type: "openFile", path })
+ }
+
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 9d9cef885..ca1d62384 100644
--- a/packages/kilo-vscode/webview-ui/src/types/messages.ts
+++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts
@@ -651,6 +651,11 @@ export interface OpenExternalRequest {
url: string
}
+export interface OpenFileRequest {
+ type: "openFile"
+ path: string
+}
+
export interface CancelLoginRequest {
type: "cancelLogin"
}
@@ -825,6 +830,7 @@ export type WebviewMessage =
| LogoutRequest
| RefreshProfileRequest
| OpenExternalRequest
+ | OpenFileRequest
| CancelLoginRequest
| SetOrganizationRequest
| WebviewReadyRequest
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 42dc37abc..6ef00a0ed 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -59,6 +59,10 @@ interface Diagnostic {
severity?: number
}
+// kilocode_change start - export for kilo-ui overrides
+export { type Diagnostic, getDiagnostics, DiagnosticsDisplay, relativizeProjectPaths, getDirectory, createThrottledValue }
+// kilocode_change end
+
function getDiagnostics(
diagnosticsByFile: Record | undefined,
filePath: string | undefined,
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index 137ea0565..2f75b07e7 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -52,6 +52,10 @@ export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise
+// kilocode_change start
+export type OpenFileFn = (path: string) => void
+// kilocode_change end
+
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -63,6 +67,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
+ onOpenFile?: OpenFileFn // kilocode_change
}) => {
return {
get store() {
@@ -77,6 +82,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
+ openFile: props.onOpenFile, // kilocode_change
}
},
})