diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index 65c6f63fae..30117708c1 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -98,6 +98,33 @@ const pierreWorkerStubPlugin = { }, } +const svgSpritePlugin = { + name: "svg-sprite-inline", + setup(build) { + build.onLoad({ filter: /sprite\.svg$/ }, (args) => { + const content = require("fs").readFileSync(args.path, "utf8") + return { + contents: ` + const svg = ${JSON.stringify(content)}; + const inject = () => { + if (!document.getElementById("kilo-sprite")) { + const el = document.createElement("div"); + el.id = "kilo-sprite"; + el.style.display = "none"; + el.innerHTML = svg; + document.body.appendChild(el); + } + }; + if (document.body) inject(); + else document.addEventListener("DOMContentLoaded", inject); + export default ""; + `, + loader: "js", + } + }) + }, +} + const cssPackageResolvePlugin = { name: "css-package-resolve", setup(build) { @@ -147,6 +174,7 @@ async function main() { plugins: [ solidDedupePlugin, pierreWorkerStubPlugin, + svgSpritePlugin, cssPackageResolvePlugin, solidPlugin(), esbuildProblemMatcherPlugin, @@ -172,6 +200,7 @@ async function main() { plugins: [ solidDedupePlugin, pierreWorkerStubPlugin, + svgSpritePlugin, cssPackageResolvePlugin, solidPlugin(), esbuildProblemMatcherPlugin, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index fb56e8aadb..3bbe1c52b0 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -276,6 +276,24 @@ export class KiloProvider implements vscode.WebviewViewProvider { this.connectionService, ) break + case "requestFileSearch": { + const client = this.httpClient + if (client) { + const dir = this.getWorkspaceDirectory() + void client + .findFiles(message.query, dir) + .then((paths) => { + this.postMessage({ type: "fileSearchResult", paths, dir, requestId: message.requestId }) + }) + .catch((error) => { + console.error("[Kilo New] File search failed:", error) + this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId }) + }) + } else { + this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId }) + } + break + } case "chatCompletionAccepted": handleChatCompletionAccepted({ type: "chatCompletionAccepted", suggestionLength: message.suggestionLength }) break diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index e3f91cc414..faf0a9a0dd 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -460,6 +460,15 @@ export class HttpClient { return this.request("POST", `/provider/${providerId}/oauth/callback`, { method }, { directory }) } + // ============================================ + // File Search Methods + // ============================================ + + async findFiles(query: string, directory: string): Promise { + const params = new URLSearchParams({ query, dirs: "false", limit: "10" }) + return this.request("GET", `/find/file?${params.toString()}`, undefined, { directory }) + } + // ============================================ // MCP Methods // ============================================ diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 62b5e99709..adff4f9707 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1,17 +1,19 @@ /** * PromptInput component - * Text input with send/abort buttons and ghost-text autocomplete for the chat interface + * Text input with send/abort buttons, ghost-text autocomplete, and @ file mention support */ -import { Component, createSignal, createEffect, on, onCleanup, Show, untrack } from "solid-js" +import { Component, createSignal, createEffect, on, onCleanup, Show, For, Index, untrack } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { FileIcon } from "@kilocode/kilo-ui/file-icon" import { useSession } from "../../context/session" import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { ModelSelector } from "./ModelSelector" import { ModeSwitcher } from "./ModeSwitcher" +import { useFileMention } from "../../hooks/useFileMention" const AUTOCOMPLETE_DEBOUNCE_MS = 500 const MIN_TEXT_LENGTH = 3 @@ -24,12 +26,16 @@ export const PromptInput: Component = () => { const server = useServer() const language = useLanguage() const vscode = useVSCode() + const mention = useFileMention(vscode) const sessionKey = () => session.currentSessionID() ?? "__new__" const [text, setText] = createSignal("") const [ghostText, setGhostText] = createSignal("") + let textareaRef: HTMLTextAreaElement | undefined + let highlightRef: HTMLDivElement | undefined + let dropdownRef: HTMLDivElement | undefined let debounceTimer: ReturnType | undefined let requestCounter = 0 // Save/restore input text when switching sessions. @@ -55,15 +61,11 @@ export const PromptInput: Component = () => { const isDisabled = () => !server.isConnected() const canSend = () => text().trim().length > 0 && !isBusy() && !isDisabled() - // Listen for chat completion results from the extension const unsubscribe = vscode.onMessage((message) => { - if (message.type === "chatCompletionResult") { - const result = message as { type: "chatCompletionResult"; text: string; requestId: string } - // Only apply if the requestId matches the latest request - const expectedId = `chat-ac-${requestCounter}` - if (result.requestId === expectedId && result.text) { - setGhostText(result.text) - } + if (message.type !== "chatCompletionResult") return + const result = message as { type: "chatCompletionResult"; text: string; requestId: string } + if (result.requestId === `chat-ac-${requestCounter}` && result.text) { + setGhostText(result.text) } }) @@ -72,29 +74,18 @@ export const PromptInput: Component = () => { const current = text() if (current) drafts.set(sessionKey(), current) unsubscribe() - if (debounceTimer) { - clearTimeout(debounceTimer) - } + if (debounceTimer) clearTimeout(debounceTimer) }) - // Request autocomplete from the extension - const requestAutocomplete = (currentText: string) => { - if (currentText.length < MIN_TEXT_LENGTH || isDisabled()) { + const requestAutocomplete = (val: string) => { + if (val.length < MIN_TEXT_LENGTH || isDisabled()) { setGhostText("") return } - requestCounter++ - const requestId = `chat-ac-${requestCounter}` - - vscode.postMessage({ - type: "requestChatCompletion", - text: currentText, - requestId, - }) + vscode.postMessage({ type: "requestChatCompletion", text: val, requestId: `chat-ac-${requestCounter}` }) } - // Accept the ghost text suggestion const acceptSuggestion = () => { const suggestion = ghostText() if (!suggestion) return @@ -102,65 +93,109 @@ export const PromptInput: Component = () => { const newText = text() + suggestion setText(newText) setGhostText("") + vscode.postMessage({ type: "chatCompletionAccepted", suggestionLength: suggestion.length }) - // Notify extension of acceptance for telemetry - vscode.postMessage({ - type: "chatCompletionAccepted", - suggestionLength: suggestion.length, - }) - - // Update textarea if (textareaRef) { textareaRef.value = newText adjustHeight() } } - // Dismiss the ghost text - const dismissSuggestion = () => { - setGhostText("") + const dismissSuggestion = () => setGhostText("") + + const scrollToActiveItem = () => { + if (!dropdownRef) return + const items = dropdownRef.querySelectorAll(".file-mention-item") + const active = items[mention.mentionIndex()] as HTMLElement | undefined + if (active) active.scrollIntoView({ block: "nearest" }) + } + + const syncHighlightScroll = () => { + if (highlightRef && textareaRef) { + highlightRef.scrollTop = textareaRef.scrollTop + } } - // Auto-resize textarea const adjustHeight = () => { if (!textareaRef) return textareaRef.style.height = "auto" textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px` } + const buildHighlightSegments = (val: string) => { + const paths = mention.mentionedPaths() + if (paths.size === 0) return [{ text: val, highlight: false }] + + const segments: { text: string; highlight: boolean }[] = [] + let remaining = val + + while (remaining.length > 0) { + let earliest = -1 + let earliestPath = "" + + for (const path of paths) { + const token = `@${path}` + const idx = remaining.indexOf(token) + if (idx !== -1 && (earliest === -1 || idx < earliest)) { + earliest = idx + earliestPath = path + } + } + + if (earliest === -1) { + segments.push({ text: remaining, highlight: false }) + break + } + + if (earliest > 0) { + segments.push({ text: remaining.substring(0, earliest), highlight: false }) + } + + const token = `@${earliestPath}` + segments.push({ text: token, highlight: true }) + remaining = remaining.substring(earliest + token.length) + } + + return segments + } + const handleInput = (e: InputEvent) => { const target = e.target as HTMLTextAreaElement - setText(target.value) + const val = target.value + setText(val) adjustHeight() - - // Clear existing ghost text on new input setGhostText("") + syncHighlightScroll() + + mention.onInput(val, target.selectionStart ?? val.length) - // Debounce autocomplete request - if (debounceTimer) { - clearTimeout(debounceTimer) + if (mention.showMention()) { + setGhostText("") + if (debounceTimer) clearTimeout(debounceTimer) + return } - debounceTimer = setTimeout(() => { - requestAutocomplete(target.value) - }, AUTOCOMPLETE_DEBOUNCE_MS) + + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => requestAutocomplete(val), AUTOCOMPLETE_DEBOUNCE_MS) } const handleKeyDown = (e: KeyboardEvent) => { - // Tab or ArrowRight to accept ghost text + if (mention.onKeyDown(e, textareaRef, setText, adjustHeight)) { + setGhostText("") + queueMicrotask(scrollToActiveItem) + return + } + if ((e.key === "Tab" || e.key === "ArrowRight") && ghostText()) { e.preventDefault() acceptSuggestion() return } - - // Escape to dismiss ghost text if (e.key === "Escape" && ghostText()) { e.preventDefault() dismissSuggestion() return } - - // Enter to send (without shift) if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() dismissSuggestion() @@ -172,26 +207,70 @@ export const PromptInput: Component = () => { const message = text().trim() if (!message || isBusy() || isDisabled()) return + const files = mention.parseFileAttachments(message) const sel = session.selected() - session.sendMessage(message, sel?.providerID, sel?.modelID) + session.sendMessage(message, sel?.providerID, sel?.modelID, files.length > 0 ? files : undefined) + + requestCounter++ setText("") setGhostText("") + if (debounceTimer) clearTimeout(debounceTimer) + mention.closeMention() drafts.delete(sessionKey()) - // Reset textarea height - if (textareaRef) { - textareaRef.style.height = "auto" - } + if (textareaRef) textareaRef.style.height = "auto" } - const handleAbort = () => { - session.abort() + const fileName = (path: string) => path.replaceAll("\\", "/").split("/").pop() ?? path + const dirName = (path: string) => { + const parts = path.replaceAll("\\", "/").split("/") + if (parts.length <= 1) return "" + const dir = parts.slice(0, -1).join("/") + return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir } return (
+ +
+ 0} + fallback={
No files found
} + > + + {(path, index) => ( +
{ + e.preventDefault() + if (textareaRef) mention.selectFile(path, textareaRef, setText, adjustHeight) + }} + onMouseEnter={() => mention.setMentionIndex(index())} + > + + {fileName(path)} + {dirName(path)} +
+ )} +
+
+
+
+