From c10689b435a3e47bf3b17151777c88b436964f08 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 16:41:32 +0100 Subject: [PATCH 1/6] feat: make filenames clickable in tool results to open in VS Code Add openFile message handler to KiloProvider and wire it through DataProvider context so that clicking filenames in read/edit/write tool results opens the file in the VS Code editor. Changes: - KiloProvider: handle 'openFile' message via showTextDocument - App.tsx DataBridge: wire onOpenFile through useVSCode postMessage - messages.ts: add OpenFileRequest type to WebviewMessage union - data.tsx: add OpenFileFn type and onOpenFile prop to DataProvider - message-part.tsx: make filenames clickable in read/edit/write tools - message-part.css: add .clickable styles for filename elements --- packages/kilo-vscode/src/KiloProvider.ts | 5 ++++ packages/kilo-vscode/webview-ui/src/App.tsx | 9 ++++-- .../webview-ui/src/types/messages.ts | 6 ++++ packages/ui/src/components/message-part.css | 10 +++++++ packages/ui/src/components/message-part.tsx | 30 +++++++++++++++++-- packages/ui/src/context/data.tsx | 4 +++ 6 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a5dabc9d98..52c8efc70b 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.path) { + await vscode.window.showTextDocument(vscode.Uri.file(message.path)) + } + 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 f940c36e46..8edda524bc 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 9d9cef885e..ca1d623849 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.css b/packages/ui/src/components/message-part.css index 44db4f9aa5..945b6f3c42 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -274,6 +274,16 @@ [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + + &.clickable { + cursor: pointer; + text-decoration: underline; + transition: color 0.15s ease; + + &:hover { + color: var(--text-strong); + } + } } [data-slot="message-part-path"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 42dc37abc5..cdac6d77ae 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -741,6 +741,9 @@ ToolRegistry.register({ 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) => ( @@ -1106,10 +1110,15 @@ 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 openFile = (e: MouseEvent) => { + e.stopPropagation() + if (props.input.filePath && data.openFile) data.openFile(props.input.filePath) + } return (
{i18n.t("ui.messagePart.title.edit")} - {filename()} + + {filename()} +
@@ -1159,10 +1174,15 @@ 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 openFile = (e: MouseEvent) => { + e.stopPropagation() + if (props.input.filePath && data.openFile) data.openFile(props.input.filePath) + } return (
{i18n.t("ui.messagePart.title.write")} - {filename()} + + {filename()} +
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 137ea05656..77ec697125 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 = (path: string) => 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, } }, }) From 5913ac469ca5456b12b915fffe702ea0d0c73c3f Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 18:00:37 +0100 Subject: [PATCH 2/6] fix: make file paths clickable in glob/grep/list output and fix path resolution - Resolve relative paths against workspace root in KiloProvider openFile handler - Add ClickableFileOutput component for tool output file paths - Make glob, grep, and list tool output file paths clickable - Make read tool 'loaded' file paths clickable - Add CSS styles for clickable file output lines --- packages/kilo-vscode/src/KiloProvider.ts | 5 +- packages/ui/src/components/message-part.css | 41 +++++++++++++++ packages/ui/src/components/message-part.tsx | 56 +++++++++++++++++++-- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 52c8efc70b..24bdfa6bc3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -318,7 +318,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break case "openFile": if (message.path) { - await vscode.window.showTextDocument(vscode.Uri.file(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": diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 945b6f3c42..dc19780df0 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -843,4 +843,45 @@ flex-shrink: 0; color: var(--icon-weak); } + + .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/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cdac6d77ae..38f7703ab5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -173,6 +173,7 @@ export function getSessionToolParts(store: ReturnType["store"], return parts } +import type { OpenFileFn } from "../context/data" import type { IconProps } from "./icon" export type ToolInfo = { @@ -532,6 +533,42 @@ export const ToolRegistry = { render: getTool, } +/** + * Renders tool output text with file paths as clickable elements. + * Lines that look like file paths (start with / or contain common path patterns) + * 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 isPath = trimmed.startsWith("/") || trimmed.match(/^[a-zA-Z]:\\/) || trimmed.match(/^\w[\w.-]*\//) + const displayPath = props.directory ? relativizeProjectPaths(trimmed, props.directory) : trimmed + return ( + {displayPath}
} + > +
props.openFile!(trimmed)} + > + {displayPath} +
+ + ) + }} + +
+ ) +} + PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const i18n = useI18n() @@ -760,7 +797,15 @@ ToolRegistry.register({ {(filepath) => (
- + { + if (data.openFile) { + e.stopPropagation() + data.openFile(filepath) + } + }} + > {i18n.t("ui.tool.loaded")} {relativizeProjectPaths(filepath, data.directory)}
@@ -774,6 +819,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "list", render(props) { + const data = useData() const i18n = useI18n() return ( {(output) => (
- +
)}
@@ -796,6 +842,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "glob", render(props) { + const data = useData() const i18n = useI18n() return ( {(output) => (
- +
)} @@ -822,6 +869,7 @@ ToolRegistry.register({ 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) @@ -839,7 +887,7 @@ ToolRegistry.register({ {(output) => (
- +
)}
From fe6a612ef37c824558baada0d7d111ffd32514f4 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 18:05:57 +0100 Subject: [PATCH 3/6] feat: make inline code file paths clickable in assistant text messages When the assistant mentions a file path in inline code (e.g. `skills/foo/SKILL.md`), clicking it now opens the file in VS Code. Uses event delegation on the text-part container to detect clicks on elements containing file-path-like text. CSS cursor:pointer applied to inline code when openFile handler is available. --- packages/ui/src/components/message-part.css | 9 ++++++++ packages/ui/src/components/message-part.tsx | 23 +++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index dc19780df0..c60ee45500 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -114,6 +114,15 @@ [data-component="text-part"] { width: 100%; + &[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="text-part-body"] { position: relative; margin-top: 32px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 38f7703ab5..274076b6b6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -703,6 +703,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { ) } +/** 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()) +} + PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() @@ -719,10 +726,22 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { 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 ( -
-
+
+
Date: Thu, 19 Feb 2026 18:11:15 +0100 Subject: [PATCH 4/6] refactor: deduplicate isFilePath - move to single definition shared by ClickableFileOutput and TextPartDisplay --- packages/ui/src/components/message-part.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 274076b6b6..deb0965da3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -533,10 +533,16 @@ export const ToolRegistry = { render: getTool, } +/** 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 (start with / or contain common path patterns) - * become clickable to open the file in the editor. + * 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())) @@ -547,11 +553,10 @@ function ClickableFileOutput(props: { text: string; openFile?: OpenFileFn; direc {(line) => { const trimmed = line.trim() - const isPath = trimmed.startsWith("/") || trimmed.match(/^[a-zA-Z]:\\/) || trimmed.match(/^\w[\w.-]*\//) const displayPath = props.directory ? relativizeProjectPaths(trimmed, props.directory) : trimmed return ( {displayPath}
} >
Date: Thu, 19 Feb 2026 18:28:40 +0100 Subject: [PATCH 5/6] refactor: move clickable file path changes from packages/ui to packages/kilo-ui packages/ui is upstream and should remain immutable. Move all component and CSS overrides to packages/kilo-ui: - Revert packages/ui/src/components/message-part.{tsx,css} to upstream - Add OpenFileFn type + onOpenFile prop to data.tsx with kilocode_change markers - Override TextPartDisplay, read, list, glob, grep, edit, write tools in kilo-ui - Add ClickableFileOutput component and isFilePath util in kilo-ui - Add all clickable CSS in kilo-ui/message-part.css - Add @opencode-ai/util as peer dependency in kilo-ui --- bun.lock | 1 + packages/kilo-ui/package.json | 1 + .../kilo-ui/src/components/message-part.css | 64 +++ .../kilo-ui/src/components/message-part.tsx | 467 ++++++++++++++++++ packages/ui/src/components/message-part.css | 60 --- packages/ui/src/components/message-part.tsx | 107 +--- packages/ui/src/context/data.tsx | 6 +- 7 files changed, 545 insertions(+), 161 deletions(-) diff --git a/bun.lock b/bun.lock index 9fb62d4cd3..5361cba8f4 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 be3e00bad7..62f3e18c6c 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 7aa79677ba..b7289bd2d9 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 a7dedd50c4..fbd518d854 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1,2 +1,469 @@ // kilocode_change - new file export * from "@opencode-ai/ui/message-part" + +import { + createMemo, + createSignal, + For, + Show, + onCleanup, + createEffect, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { TextPart, ToolPart } from "@kilocode/sdk/v2" +import { ToolRegistry, PART_MAPPING } 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 { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { checksum } from "@opencode-ai/util/encode" +import type { OpenFileFn } from "@opencode-ai/ui/context/data" + +function relativizeProjectPaths(text: string, directory?: string) { + if (!text) return "" + if (!directory) return text + return text.split(directory).join("") +} + +function getDirectory(path: string | undefined) { + const data = useData() + return relativizeProjectPaths(_getDirectory(path), data.directory) +} + +interface Diagnostic { + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } + message: string + severity?: number +} + +function getDiagnostics( + diagnosticsByFile: Record | undefined, + filePath: string | undefined, +): Diagnostic[] { + if (!diagnosticsByFile || !filePath) return [] + const diagnostics = diagnosticsByFile[filePath] ?? [] + return diagnostics.filter((d) => d.severity === 1).slice(0, 3) +} + +function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }) { + const i18n = useI18n() + return ( + 0}> +
+ + {(diagnostic) => ( +
+ {i18n.t("ui.messagePart.diagnostic.error")} + + [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] + + {diagnostic.message} +
+ )} +
+
+
+ ) +} + +/** 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} +
+ + ) + }} + +
+ ) +} + +const TEXT_RENDER_THROTTLE_MS = 100 + +function createThrottledValue(getValue: () => string) { + const [value, setValue] = createSignal(getValue()) + let timeout: ReturnType | undefined + let last = 0 + + createEffect(() => { + const next = getValue() + const now = Date.now() + const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) + if (remaining <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + last = now + setValue(next) + return + } + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + last = Date.now() + setValue(next) + timeout = undefined + }, remaining) + }) + + onCleanup(() => { + if (timeout) clearTimeout(timeout) + }) + + return value +} + +// 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 ( + + +
+ )} + + + ) + }, +}) + +// 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/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index c60ee45500..44db4f9aa5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -114,15 +114,6 @@ [data-component="text-part"] { width: 100%; - &[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="text-part-body"] { position: relative; margin-top: 32px; @@ -283,16 +274,6 @@ [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ - - &.clickable { - cursor: pointer; - text-decoration: underline; - transition: color 0.15s ease; - - &:hover { - color: var(--text-strong); - } - } } [data-slot="message-part-path"] { @@ -852,45 +833,4 @@ flex-shrink: 0; color: var(--icon-weak); } - - .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/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index deb0965da3..42dc37abc5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -173,7 +173,6 @@ export function getSessionToolParts(store: ReturnType["store"], return parts } -import type { OpenFileFn } from "../context/data" import type { IconProps } from "./icon" export type ToolInfo = { @@ -533,47 +532,6 @@ export const ToolRegistry = { render: getTool, } -/** 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} -
- - ) - }} - -
- ) -} - PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const i18n = useI18n() @@ -724,22 +682,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { 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 ( -
-
+
+
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)}
@@ -836,7 +770,6 @@ ToolRegistry.register({ ToolRegistry.register({ name: "list", render(props) { - const data = useData() const i18n = useI18n() return ( {(output) => (
- +
)} @@ -859,7 +792,6 @@ ToolRegistry.register({ ToolRegistry.register({ name: "glob", render(props) { - const data = useData() const i18n = useI18n() return ( {(output) => (
- +
)} @@ -886,7 +818,6 @@ ToolRegistry.register({ 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) @@ -904,7 +835,7 @@ ToolRegistry.register({ {(output) => (
- +
)}
@@ -1175,15 +1106,10 @@ 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 openFile = (e: MouseEvent) => { - e.stopPropagation() - if (props.input.filePath && data.openFile) data.openFile(props.input.filePath) - } return (
{i18n.t("ui.messagePart.title.edit")} - - {filename()} - + {filename()}
@@ -1239,15 +1159,10 @@ 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 openFile = (e: MouseEvent) => { - e.stopPropagation() - if (props.input.filePath && data.openFile) data.openFile(props.input.filePath) - } return (
{i18n.t("ui.messagePart.title.write")} - - {filename()} - + {filename()}
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 77ec697125..2f75b07e71 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -52,7 +52,9 @@ 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", @@ -65,7 +67,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 +82,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 } }, }) From 2def3ddeca4bf9fd7bc02d0d11af4e554b1b1dab Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Feb 2026 18:38:05 +0100 Subject: [PATCH 6/6] refactor: export upstream helpers instead of duplicating them in kilo-ui Export DiagnosticsDisplay, getDiagnostics, relativizeProjectPaths, getDirectory, and createThrottledValue from upstream message-part.tsx with kilocode_change markers. Import them in kilo-ui instead of re-defining. --- .../kilo-ui/src/components/message-part.tsx | 101 ++---------------- packages/ui/src/components/message-part.tsx | 4 + 2 files changed, 15 insertions(+), 90 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index fbd518d854..55fa513c68 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -6,12 +6,18 @@ import { createSignal, For, Show, - onCleanup, - createEffect, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { TextPart, ToolPart } from "@kilocode/sdk/v2" -import { ToolRegistry, PART_MAPPING } from "@opencode-ai/ui/message-part" +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" @@ -22,60 +28,10 @@ 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 { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import type { OpenFileFn } from "@opencode-ai/ui/context/data" -function relativizeProjectPaths(text: string, directory?: string) { - if (!text) return "" - if (!directory) return text - return text.split(directory).join("") -} - -function getDirectory(path: string | undefined) { - const data = useData() - return relativizeProjectPaths(_getDirectory(path), data.directory) -} - -interface Diagnostic { - range: { - start: { line: number; character: number } - end: { line: number; character: number } - } - message: string - severity?: number -} - -function getDiagnostics( - diagnosticsByFile: Record | undefined, - filePath: string | undefined, -): Diagnostic[] { - if (!diagnosticsByFile || !filePath) return [] - const diagnostics = diagnosticsByFile[filePath] ?? [] - return diagnostics.filter((d) => d.severity === 1).slice(0, 3) -} - -function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }) { - const i18n = useI18n() - return ( - 0}> -
- - {(diagnostic) => ( -
- {i18n.t("ui.messagePart.diagnostic.error")} - - [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] - - {diagnostic.message} -
- )} -
-
-
- ) -} - /** 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+$/ @@ -117,41 +73,6 @@ function ClickableFileOutput(props: { text: string; openFile?: OpenFileFn; direc ) } -const TEXT_RENDER_THROTTLE_MS = 100 - -function createThrottledValue(getValue: () => string) { - const [value, setValue] = createSignal(getValue()) - let timeout: ReturnType | undefined - let last = 0 - - createEffect(() => { - const next = getValue() - const now = Date.now() - const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) - if (remaining <= 0) { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - last = now - setValue(next) - return - } - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - last = Date.now() - setValue(next) - timeout = undefined - }, remaining) - }) - - onCleanup(() => { - if (timeout) clearTimeout(timeout) - }) - - return value -} - // Override TextPartDisplay to make inline code file paths clickable PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 42dc37abc5..6ef00a0ed1 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,