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 ( + + + + )} + + + ) + }, +}) + +// 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 } }, })