From 6fd32270ae4181dffc0d37c3117bc6beca228962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 17 Feb 2026 20:41:11 +0100 Subject: [PATCH 01/28] feat(vscode): add file search API plumbing for @ mention feature Add findFiles() to HttpClient, add RequestFileSearchMessage/FileSearchResultMessage message types, and handle requestFileSearch in KiloProvider. --- packages/kilo-vscode/src/KiloProvider.ts | 10 ++++++++++ .../src/services/cli-backend/http-client.ts | 9 +++++++++ .../kilo-vscode/webview-ui/src/types/messages.ts | 14 ++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 46b931710c..7b96eb7790 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -276,6 +276,16 @@ 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, 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/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index af25e6b7e0..92d2169e9c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -446,6 +446,12 @@ export interface ChatCompletionResultMessage { requestId: string } +export interface FileSearchResultMessage { + type: "fileSearchResult" + paths: string[] + requestId: string +} + export interface QuestionRequestMessage { type: "questionRequest" question: QuestionRequest @@ -519,6 +525,7 @@ export type ExtensionMessage = | AgentsLoadedMessage | AutocompleteSettingsLoadedMessage | ChatCompletionResultMessage + | FileSearchResultMessage | QuestionRequestMessage | QuestionResolvedMessage | QuestionErrorMessage @@ -663,6 +670,12 @@ export interface RequestChatCompletionMessage { requestId: string } +export interface RequestFileSearchMessage { + type: "requestFileSearch" + query: string + requestId: string +} + export interface ChatCompletionAcceptedMessage { type: "chatCompletionAccepted" suggestionLength?: number @@ -716,6 +729,7 @@ export type WebviewMessage = | RequestAutocompleteSettingsMessage | UpdateAutocompleteSettingMessage | RequestChatCompletionMessage + | RequestFileSearchMessage | ChatCompletionAcceptedMessage | UpdateSettingRequest | RequestBrowserSettingsMessage From 7725520164377ac15974559dfe58e294a668661c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 17 Feb 2026 20:42:47 +0100 Subject: [PATCH 02/28] feat(vscode): implement @ file mention with dropdown and file chips - Detect @ pattern in textarea and show fuzzy file search dropdown - Keyboard navigation (arrows, Enter/Tab to select, Escape to close) - Selected files appear as removable chips below the textarea - Chips are sent as FileAttachment[] with the message on submit --- .../src/components/chat/PromptInput.tsx | 193 +++++++++++++++--- .../webview-ui/src/styles/chat.css | 87 ++++++++ 2 files changed, 252 insertions(+), 28 deletions(-) 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 adeed77425..087d38c52a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1,9 +1,9 @@ /** * 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, onCleanup, Show } from "solid-js" +import { Component, createSignal, onCleanup, Show, For, createEffect } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" @@ -12,10 +12,14 @@ import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { ModelSelector } from "./ModelSelector" import { ModeSwitcher } from "./ModeSwitcher" +import type { FileAttachment } from "../../types/messages" const AUTOCOMPLETE_DEBOUNCE_MS = 500 +const FILE_SEARCH_DEBOUNCE_MS = 150 const MIN_TEXT_LENGTH = 3 +const AT_PATTERN = /@(\S*)$/ + export const PromptInput: Component = () => { const session = useSession() const server = useServer() @@ -24,34 +28,53 @@ export const PromptInput: Component = () => { const [text, setText] = createSignal("") const [ghostText, setGhostText] = createSignal("") + const [attachedFiles, setAttachedFiles] = createSignal([]) + const [mentionQuery, setMentionQuery] = createSignal(null) + const [mentionResults, setMentionResults] = createSignal([]) + const [mentionIndex, setMentionIndex] = createSignal(0) + let textareaRef: HTMLTextAreaElement | undefined let debounceTimer: ReturnType | undefined + let fileSearchTimer: ReturnType | undefined let requestCounter = 0 + let fileSearchCounter = 0 const isBusy = () => session.status() === "busy" const isDisabled = () => !server.isConnected() const canSend = () => text().trim().length > 0 && !isBusy() && !isDisabled() + const showMention = () => mentionQuery() !== null && mentionResults().length > 0 + + createEffect(() => { + if (!showMention()) { + setMentionIndex(0) + } + }) - // 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 === "fileSearchResult") { + const result = message as { type: "fileSearchResult"; paths: string[]; requestId: string } + const expectedId = `file-search-${fileSearchCounter}` + if (result.requestId === expectedId) { + setMentionResults(result.paths) + setMentionIndex(0) + } + } }) onCleanup(() => { unsubscribe() - if (debounceTimer) { - clearTimeout(debounceTimer) - } + if (debounceTimer) clearTimeout(debounceTimer) + if (fileSearchTimer) clearTimeout(fileSearchTimer) }) - // Request autocomplete from the extension const requestAutocomplete = (currentText: string) => { if (currentText.length < MIN_TEXT_LENGTH || isDisabled()) { setGhostText("") @@ -59,16 +82,25 @@ export const PromptInput: Component = () => { } requestCounter++ - const requestId = `chat-ac-${requestCounter}` - vscode.postMessage({ type: "requestChatCompletion", text: currentText, - requestId, + requestId: `chat-ac-${requestCounter}`, }) } - // Accept the ghost text suggestion + const requestFileSearch = (query: string) => { + if (fileSearchTimer) clearTimeout(fileSearchTimer) + fileSearchTimer = setTimeout(() => { + fileSearchCounter++ + vscode.postMessage({ + type: "requestFileSearch", + query, + requestId: `file-search-${fileSearchCounter}`, + }) + }, FILE_SEARCH_DEBOUNCE_MS) + } + const acceptSuggestion = () => { const suggestion = ghostText() if (!suggestion) return @@ -77,64 +109,120 @@ export const PromptInput: Component = () => { setText(newText) setGhostText("") - // 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("") } - // Auto-resize textarea const adjustHeight = () => { if (!textareaRef) return textareaRef.style.height = "auto" textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px` } + const closeMention = () => { + setMentionQuery(null) + setMentionResults([]) + } + + const selectMentionFile = (path: string) => { + if (!textareaRef) return + + const val = textareaRef.value + const cursor = textareaRef.selectionStart ?? val.length + const before = val.substring(0, cursor) + const after = val.substring(cursor) + + const replaced = before.replace(AT_PATTERN, "") + const newText = replaced + after + setText(newText) + textareaRef.value = newText + + const newCursor = replaced.length + textareaRef.setSelectionRange(newCursor, newCursor) + textareaRef.focus() + adjustHeight() + + setAttachedFiles((prev) => (prev.includes(path) ? prev : [...prev, path])) + closeMention() + } + + const removeFile = (path: string) => { + setAttachedFiles((prev) => prev.filter((p) => p !== path)) + } + 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("") - // Debounce autocomplete request - if (debounceTimer) { - clearTimeout(debounceTimer) + const cursor = target.selectionStart ?? val.length + const before = val.substring(0, cursor) + const match = before.match(AT_PATTERN) + + if (match) { + const query = match[1] + setMentionQuery(query) + requestFileSearch(query) + } else { + closeMention() } + + if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { - requestAutocomplete(target.value) + requestAutocomplete(val) }, AUTOCOMPLETE_DEBOUNCE_MS) } const handleKeyDown = (e: KeyboardEvent) => { - // Tab or ArrowRight to accept ghost text + if (showMention()) { + if (e.key === "ArrowDown") { + e.preventDefault() + setMentionIndex((i) => Math.min(i + 1, mentionResults().length - 1)) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + setMentionIndex((i) => Math.max(i - 1, 0)) + return + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault() + const path = mentionResults()[mentionIndex()] + if (path) selectMentionFile(path) + return + } + if (e.key === "Escape") { + e.preventDefault() + closeMention() + 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() @@ -146,12 +234,18 @@ export const PromptInput: Component = () => { const message = text().trim() if (!message || isBusy() || isDisabled()) return + const files = attachedFiles().map((path) => ({ + mime: "text/plain", + url: `file://${path}`, + })) + const sel = session.selected() - session.sendMessage(message, sel?.providerID, sel?.modelID) + session.sendMessage(message, sel?.providerID, sel?.modelID, files.length > 0 ? files : undefined) setText("") setGhostText("") + setAttachedFiles([]) + closeMention() - // Reset textarea height if (textareaRef) { textareaRef.style.height = "auto" } @@ -161,8 +255,37 @@ export const PromptInput: Component = () => { session.abort() } + const shortPath = (path: string) => { + const parts = path.split("/") + return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : path + } + return (
+ +
+ + {(path, index) => ( +
{ + e.preventDefault() + selectMentionFile(path) + }} + onMouseEnter={() => setMentionIndex(index())} + > + + + + + + {shortPath(path)} +
+ )} +
+
+