From 8e9411e3a7bed4ae68ca85df1b930c4ba3736a1b Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Mar 2026 10:31:56 +0100 Subject: [PATCH 01/45] docs(vscode): add chat autocomplete improvement plan Compare current SolidJS implementation with legacy React extension, document 5 bugs and propose 5-phase improvement plan covering bug fixes, ghost-text state centralization, rich mention types, slash commands, and architectural cleanup. --- .../CHAT_AUTOCOMPLETE_IMPROVEMENT_PLAN.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/kilo-vscode/CHAT_AUTOCOMPLETE_IMPROVEMENT_PLAN.md diff --git a/packages/kilo-vscode/CHAT_AUTOCOMPLETE_IMPROVEMENT_PLAN.md b/packages/kilo-vscode/CHAT_AUTOCOMPLETE_IMPROVEMENT_PLAN.md new file mode 100644 index 00000000000..5a6675ea738 --- /dev/null +++ b/packages/kilo-vscode/CHAT_AUTOCOMPLETE_IMPROVEMENT_PLAN.md @@ -0,0 +1,255 @@ +# Chat Autocomplete Improvement Plan + +## Current State Comparison + +### Legacy Extension (kilocode-legacy-5) + +**Framework:** React with `useState`/`useCallback`/`useMemo`/`useRef` + +**Architecture:** + +- Single monolithic `ChatTextArea.tsx` (~2045 lines) manages everything: `@` mentions, `/` slash commands, ghost-text FIM autocomplete, STT, image paste, prompt history +- Uses React's natural re-render cycle for state synchronization — when `inputValue`, `showContextMenu`, `searchQuery`, or `selectedMenuIndex` change, the component re-renders and everything stays consistent +- Mention state (`showContextMenu`, `searchQuery`, `selectedMenuIndex`, `selectedType`, `fileSearchResults`) lives as `useState` in the component — React guarantees consistency across re-renders +- Ghost-text autocomplete is extracted into `useChatAutocompleteText` hook with a **`syncAutocompleteTextVisibility()` function** — a single idempotent function that reconciles all state (focus, cursor position, prefix match) into a yes/no visibility decision. This is called from every event handler instead of each handler independently deciding what to show/hide +- `ContextMenu.tsx` is a pure presentational component — it receives `filteredOptions`, `selectedIndex`, `onSelect` as props and just renders. All filtering logic lives in `getContextMenuOptions()` pure function +- File search uses `fzf` (fuzzy matching) client-side on a pre-loaded `filePaths` array from extension state, plus server-side `searchFiles` for deeper results. Both contribute to `getContextMenuOptions()` output + +**Key Design Decisions:** + +- Mention types are rich: Files, Folders, Git commits, Problems, Terminal, URLs, Images, Modes — all through one unified `ContextMenu` with `ContextMenuOptionType` enum +- Slash commands are separate from mentions (`SlashCommandMenu` component, `shouldShowSlashCommandsMenu()` logic) but share the same keyboard navigation pattern +- `insertMention()` and `removeMention()` are pure functions that handle text manipulation with space escaping + +### Current Extension (kilocode-3) + +**Framework:** SolidJS with `createSignal`/`createEffect` + +**Architecture:** + +- `PromptInput.tsx` (~755 lines) is the main component +- `@` file mention logic extracted to `useFileMention.ts` hook (173 lines) + `file-mention-utils.ts` (48 lines) +- Ghost-text FIM autocomplete is inline in `PromptInput.tsx` (not extracted to a hook) +- **No slash command support** — i18n keys exist but no implementation +- **Only file mentions** — no folders, git commits, problems, terminal, URLs, images, or modes via `@` + +**State Management Approach:** + +- SolidJS signals for reactive state (`text`, `ghostText`, `mentionQuery`, `mentionResults`, `mentionIndex`) +- Plain `let` variables for non-reactive state (`workspaceDir`, `requestCounter`, `debounceTimer`, `fileSearchCounter`) +- Event-driven via `vscode.onMessage()` handler that imperatively updates signals based on message type +- Each event handler independently manages ghost text state: `handleInput` clears it, `handleKeyDown` clears it in multiple places, `handleSend` clears it — no single source of truth + +## Key Differences + +| Aspect | Legacy (React) | Current (SolidJS) | +| ----------------------- | ------------------------------------------------------------ | ----------------------------------------------- | +| Mention types | Files, folders, git, problems, terminal, URLs, images, modes | Files only | +| Slash commands | Full `/command` menu with fuzzy matching | None | +| Ghost-text state mgmt | Single `syncAutocompleteTextVisibility()` function | Scattered across 8+ locations | +| File search | Client-side fzf on pre-loaded paths + server search | Server-only search with 150ms debounce | +| Mention text handling | `insertMention()` pure function with space escaping | Inline regex replacement in `selectMentionFile` | +| Space after mention | Automatically inserted | Missing — next word concatenates with path | +| Keyboard navigation | Unified across context menu + slash commands | File mentions only | +| Scroll to active item | `useEffect` on `selectedIndex` with direct DOM measurement | `queueMicrotask` with `scrollIntoView` | +| Stale response handling | Random request ID string | Incrementing counter + string prefix comparison | +| Pre-loaded file list | `filePaths` + `openedTabs` from extension state context | Not available — fully server-dependent | + +## Bugs in Current Implementation + +### Bug 1: "No files found" flash on every keystroke + +**File:** `useFileMention.ts:112-121` +When user types `@`, `mentionQuery` is set immediately (showing the dropdown), but file search is debounced 150ms. During the gap, `mentionResults()` is stale/empty, showing "No files found" flash. + +**Fix:** Don't show the dropdown until results arrive, or show a loading state, or keep previous results while loading. + +### Bug 2: No space inserted after selecting a mention + +**File:** `useFileMention.ts:99` +After selecting a file, the cursor is placed right after `@path`. User's next keystrokes concatenate with the path. Legacy inserts a space: `"@" + processedValue + " "`. + +**Fix:** Append a space after the path in `selectMentionFile`. + +### Bug 3: `buildTextAfterMentionSelect` is dead code + +**File:** `file-mention-utils.ts:21` +The function exists and is tested, but `selectMentionFile` in `useFileMention.ts:90-99` duplicates the logic inline. + +**Fix:** Either use the utility function or delete it. + +### Bug 4: `syncMentionedPaths` false positives + +**File:** `file-mention-utils.ts:12` +`text.includes(\`@${path}\`)`matches substrings: if mentioned path is`a.ts`, the text `@a.tsx` still matches. Path stays in set incorrectly. + +**Fix:** Use a word-boundary regex: `/(?:^|\s)@${escapedPath}(?:\s|$)/` or check that the character after the path is whitespace/end. + +### Bug 5: `workspaceDir` is never set before first search + +**File:** `useFileMention.ts:41` +If user types `@path` manually (without using the dropdown) and sends, `workspaceDir` is `""`, producing malformed `file://` URLs. + +**Fix:** Request `workspaceDir` at mount time, or make it a signal initialized from server state. + +### Bug 6: Ghost text cleared in too many places, not enough + +**File:** `PromptInput.tsx` lines 225, 234, 246, 274, 301, 401, 408, 433, 449, 499, 534 +Ghost text is imperatively cleared in 11 different places. Missing: clicking in the middle of text (partially handled by `clearIfNotAtEnd` on `onClick`/`onSelect`/`onKeyUp` but not on focus changes or window blur). + +### Bug 7: Ghost-text debounce timer not cleared on unmount race + +**File:** `PromptInput.tsx:331` +If the component unmounts during a debounce, the timer fires and calls `requestAutocomplete`, which posts a message to a potentially dead webview. + +**Fix:** Already handled in `onCleanup` at line 331, but `requestAutocomplete` itself doesn't check if the component is still mounted before posting. + +## Improvement Plan + +### Phase 1: Fix Critical Bugs (Low risk) + +**1.1 — Insert space after mention selection** + +- In `useFileMention.ts:selectMentionFile`, change line 99 to: `const newText = replaced + " " + after` +- Update cursor position: `const newCursor = replaced.length + 1` +- Update tests + +**1.2 — Fix "No files found" flash** + +- Add a `loading` signal to `useFileMention` +- Set `loading(true)` when `requestFileSearch` is called, `loading(false)` when results arrive +- In the dropdown, show "Searching..." while loading instead of "No files found" +- Alternatively: keep showing previous results until new ones arrive (legacy's approach: "Don't clear results until we have new ones. This prevents flickering.") + +**1.3 — Fix `syncMentionedPaths` substring matching** + +- Replace `text.includes(\`@${path}\`)` with a regex that requires a word boundary after the path +- Add test cases for the `a.ts` / `a.tsx` scenario + +**1.4 — Use `buildTextAfterMentionSelect` or delete it** + +- Refactor `selectMentionFile` to call `buildTextAfterMentionSelect` (after fixing it to include trailing space) +- Or delete it and its tests if the inline approach is preferred + +**1.5 — Initialize `workspaceDir` properly** + +- Convert `workspaceDir` to a signal +- Request it from the extension at mount time (add a `requestWorkspaceDir` message) +- Or derive it from the server connection's session directory + +### Phase 2: Extract and Centralize Ghost-Text State (Medium risk) + +**2.1 — Extract ghost-text autocomplete to a hook** +Create `useGhostText.ts` hook (mirroring legacy's `useChatAutocompleteText`): + +``` +interface GhostText { + text: Accessor + accept: () => void + dismiss: () => void + sync: () => void // single source of truth function + onInput: (val: string) => void + onKeyDown: (e: KeyboardEvent) => boolean + enabled: Accessor +} +``` + +Key design: a single `sync()` function that checks (1) focus, (2) cursor at end, (3) prefix still matches, (4) not in mention mode — and sets ghost text visibility accordingly. Every event handler calls `sync()` instead of independently managing `setGhostText("")`. + +**2.2 — Clean up PromptInput.tsx** + +- Remove all inline ghost text management from `PromptInput.tsx` +- Wire `handleInput`, `handleKeyDown`, `clearIfNotAtEnd` to the hook +- Reduces `PromptInput.tsx` by ~80 lines + +### Phase 3: Rich Mention Types (Medium risk, high value) + +**3.1 — Add mention type system** +Introduce a `MentionType` enum similar to legacy's `ContextMenuOptionType`: + +``` +enum MentionType { + File = "file", + Folder = "folder", + Problems = "problems", + Terminal = "terminal", + URL = "url", + Git = "git", +} +``` + +**3.2 — Add top-level category menu** +When user types just `@` (empty query), show categories: + +- Problems, Terminal, URL, Folder, File, Git +- Selecting a category drills into it (like legacy) + +**3.3 — Add client-side fuzzy matching** + +- Pre-load open tabs and workspace file list from the server +- Use `fzf` or similar for instant client-side matching +- Fall back to server search for deep queries +- This eliminates the 150ms debounce latency for common files + +### Phase 4: Slash Commands (Medium risk, high value) + +**4.1 — Add `/` command detection** + +- Detect `/` at start of input (like legacy's `shouldShowSlashCommandsMenu`) +- Show a command dropdown above the input + +**4.2 — Implement command menu** + +- Port `SlashCommandMenu` component to SolidJS +- Support mode switching, `/newtask`, and workflow commands +- Use fuzzy matching on command names + +**4.3 — Wire keyboard navigation** + +- Share the arrow key / enter / tab / escape pattern with mentions +- Ensure slash commands and mentions are mutually exclusive (legacy's approach) + +### Phase 5: Architectural Cleanup (Low risk) + +**5.1 — Consolidate event handler into a dispatch pattern** +Replace the chain of `if (message.type === ...)` in the `vscode.onMessage` handler with a dispatch map: + +```ts +const handlers: Record void> = { + chatCompletionResult: handleCompletionResult, + fileSearchResult: handleFileSearchResult, + autocompleteSettingsLoaded: handleSettingsLoaded, + // ... +} +``` + +**5.2 — Fix extension-side per-request instantiation** +`handleChatCompletionRequest.ts` creates new `FileIgnoreController`, `VisibleCodeTracker`, and `ChatTextAreaAutocomplete` on every request. These should be cached/reused. + +**5.3 — Fix telemetry instance mismatch** +`handleChatCompletionAccepted.ts` uses a singleton `AutocompleteTelemetry`, while `handleChatCompletionRequest.ts` creates a new one per request. Unify to a single shared instance. + +## Priority Order + +| Priority | Phase | Effort | Impact | +| -------- | -------------------------------- | --------- | ------------------------------------------------ | +| P0 | 1.1 Space after mention | 15 min | Fixes daily annoyance | +| P0 | 1.2 Flash on empty results | 30 min | Fixes jarring visual bug | +| P0 | 1.3 Substring false positive | 30 min | Fixes incorrect mention tracking | +| P1 | 2.1-2.2 Ghost-text hook | 2-3 hours | Reduces scattered state bugs, easier maintenance | +| P1 | 1.4-1.5 Dead code + workspaceDir | 1 hour | Code quality + edge case fix | +| P2 | 3.1-3.3 Rich mentions + fuzzy | 4-6 hours | Feature parity with legacy, faster UX | +| P2 | 4.1-4.3 Slash commands | 3-4 hours | Feature parity with legacy | +| P3 | 5.1-5.3 Architectural cleanup | 2-3 hours | Maintainability | + +## Design Principles (from Legacy) + +1. **Single source of truth for visibility**: Legacy's `syncAutocompleteTextVisibility()` is the key pattern. One idempotent function that derives visibility from all relevant state. Call it everywhere instead of hand-managing show/hide in each handler. + +2. **Keep previous results while loading**: Don't clear search results until new ones arrive. This prevents flickering. + +3. **Pure functions for text manipulation**: `insertMention()`, `removeMention()`, `shouldShowContextMenu()` are all pure functions. The current codebase already does this for some utils but not consistently. + +4. **Client-side fuzzy matching first**: Pre-load the file list and do instant fuzzy matching. Only hit the server for deep/expensive queries. This makes the dropdown feel instant. + +5. **Space after mentions**: Always insert a trailing space after selecting a mention. This is the standard in every mention system (Slack, GitHub, Discord, etc.). From 204c6767742ec7c16c7008b0290e116d10e18c91 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Mar 2026 11:14:06 +0100 Subject: [PATCH 02/45] docs(vscode): update chat autocomplete improvement plan with detailed comparison --- .../chat-autocomplete-improvement-plan.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 packages/kilo-vscode/docs/chat-autocomplete-improvement-plan.md diff --git a/packages/kilo-vscode/docs/chat-autocomplete-improvement-plan.md b/packages/kilo-vscode/docs/chat-autocomplete-improvement-plan.md new file mode 100644 index 00000000000..9dbad48b3f3 --- /dev/null +++ b/packages/kilo-vscode/docs/chat-autocomplete-improvement-plan.md @@ -0,0 +1,184 @@ +# Chat Autocomplete Improvement Plan + +## Problem Statement + +The current chat autocomplete (ghost text + @-mention) implementation is brittle and event-driven in a way that makes state hard to reason about. The legacy extension (`kilocode-legacy-5`) had a more reactive, centralized architecture that was more robust. This plan outlines concrete changes to bring the current implementation to parity and beyond. + +--- + +## Current Architecture (kilo-vscode) + +| Subsystem | Files | +| ----------------------- | --------------------------------------------------------------------------- | +| Ghost text autocomplete | `PromptInput.tsx` (inline), extension-side `handleChatCompletionRequest.ts` | +| @-mention file search | `useFileMention.ts` hook, `file-mention-utils.ts` | +| Highlight overlay | Inline in `PromptInput.tsx` | + +**Key characteristics:** + +- SolidJS signals for local UI state +- `postMessage` / `onMessage` request-response with `requestId` correlation +- Ghost text and @-mention logic are interleaved in `PromptInput.tsx`'s event handlers +- No single source of truth for ghost text visibility — show/hide logic is scattered across `handleInput`, `handleKeyDown`, `handleFocus`, `handleBlur`, and the message listener + +## Legacy Architecture (kilocode-legacy-5) + +| Subsystem | Files | +| ----------------------- | ----------------------------------------------------- | +| Ghost text autocomplete | `useChatAutocompleteText.ts` hook | +| @-mention context menu | `context-mentions.ts` utils + `ContextMenu.tsx` | +| /slash commands | `slash-commands.ts` utils + `SlashCommandMenu.tsx` | +| Highlight overlay | Managed by `updateHighlights()` in `ChatTextArea.tsx` | + +**Key characteristics:** + +- Each subsystem is a standalone module with its own state and trigger logic +- Ghost text has a **single idempotent sync function** (`syncAutocompleteTextVisibility`) that derives visibility from current state — called from every relevant handler +- Priority-based `handleKeyDown` chain: slash commands > @-mentions > ghost text > send +- `useRef` for internal tracking (focus, debounce, request ID, prefix), `useState` only for rendered ghost text — minimizes re-renders +- Ghost text is saved on blur and restored on focus +- Uses `document.execCommand("insertText")` for undo-preserving acceptance + +--- + +## Identified Bugs and Fragile Patterns + +### 1. No single source of truth for ghost text visibility + +**Impact: High** + +Ghost text show/hide logic is scattered across multiple event handlers in `PromptInput.tsx`. Each handler independently decides whether to clear or keep ghost text. This makes it easy for edge cases to leave ghost text visible when it shouldn't be (or vice versa). + +**Fix:** Extract a `syncGhostText()` function (following the legacy pattern) that idempotently derives whether ghost text should be visible based on current state: focused, cursor at end, prefix matches current text, @-mention not open. Call this from every handler instead of inline show/hide logic. + +### 2. No cancellation of in-flight LLM requests + +**Impact: High** + +When the user types a new character, `requestCounter` increments but the old FIM request keeps streaming. `AutocompleteModel.generateFimResponse()` accepts an `AbortSignal` but nobody passes one. Multiple overlapping LLM requests pile up, wasting compute. + +**Fix:** Create an `AbortController` per request in `handleChatCompletionRequest`. Pass its signal to `generateFimResponse()`. Abort the previous controller when a new request arrives. On the webview side, send a `cancelChatCompletion` message when the user types (before the debounce fires) so the extension can abort immediately. + +### 3. New `ChatTextAreaAutocomplete` + `FileIgnoreController` + `VisibleCodeTracker` created per request + +**Impact: Medium** + +Every 500ms keystroke creates new instances including I/O-heavy `FileIgnoreController.initialize()`. These should be cached and reused. + +**Fix:** Create these once in `KiloProvider` (or a dedicated autocomplete service) and reuse across requests. The `VisibleCodeTracker` snapshot can be refreshed per request without recreating the whole object. + +### 4. Telemetry instance mismatch + +**Impact: Medium** + +`handleChatCompletionRequest` creates a per-request `AutocompleteTelemetry` inside each `ChatTextAreaAutocomplete`, but `handleChatCompletionAccepted` uses a separate singleton. Request and acceptance events go to different instances, breaking session-level correlation. + +**Fix:** Use a single shared telemetry instance across request and acceptance handlers. + +### 5. `syncMentionedPaths` uses naive substring matching + +**Impact: Medium** + +`text.includes(@${path})` can false-positive when one path is a prefix of another (e.g., `src/a.ts` vs `src/a.tsx`). Also matches `@path` appearing anywhere in text, not just as standalone tokens. + +**Fix:** Use a regex with word-boundary or whitespace checks: `(?:^|\s)@path(?:\s|$)`. Process paths longest-first to prevent prefix shadowing. + +### 6. `selectMentionFile` duplicates `buildTextAfterMentionSelect` + +**Impact: Low** + +The hook inlines the same regex replacement logic that the tested utility function provides. + +**Fix:** Call `buildTextAfterMentionSelect()` from `selectMentionFile()` instead of duplicating the logic. + +### 7. Overlay scroll sync gaps + +**Impact: Low** + +`syncHighlightScroll()` is called on `onScroll`, `onInput`, `onPaste` but not on programmatic scrolling (e.g., `textareaRef.scrollTop = textareaRef.scrollHeight` in `appendChatBoxMessage`). + +**Fix:** Call `syncHighlightScroll()` after every programmatic scroll change, or use a `MutationObserver` / `ResizeObserver` to keep them in sync. + +### 8. Ghost text not preserved across blur/focus + +**Impact: Low** + +Unlike the legacy implementation, the current code doesn't save ghost text on blur and restore it on focus. If the user clicks away and comes back, the ghost text is gone. + +**Fix:** Save ghost text + prefix on blur. On focus, if the text hasn't changed, restore the saved ghost text. + +--- + +## Implementation Plan + +### Phase 1: Extract and centralize ghost text logic + +**Goal:** Single source of truth for ghost text state, matching the legacy `useChatAutocompleteText` pattern. + +1. Create `useGhostText.ts` hook that owns: + - `ghostText` signal (the displayed completion string) + - `prefix` tracking (text when request was made) + - `requestId` counter + - Debounce timer + - `syncVisibility()` — idempotent function that derives whether ghost text should show + - `request()` — triggers a new completion request + - `accept()` / `acceptWord()` / `dismiss()` — user actions + - Blur/focus save/restore + +2. Remove all ghost text logic from `PromptInput.tsx` event handlers. Replace with calls to the hook's methods. + +3. Wire up `syncVisibility()` to be called from: `onInput`, `onFocus`, `onBlur`, `onSelect` (cursor change), `onKeyDown`, and the message listener. + +**Estimated effort:** 1-2 days + +### Phase 2: Add request cancellation + +1. In the extension side, maintain a single `AbortController` for the current autocomplete request. +2. Abort the previous request when a new one arrives. +3. Pass the `AbortSignal` through to `AutocompleteModel.generateFimResponse()`. +4. Add a `cancelChatCompletion` message type so the webview can proactively cancel. + +**Estimated effort:** 0.5 days + +### Phase 3: Cache per-request objects + +1. Create an `AutocompleteService` class (or similar) in the extension that holds: + - A cached `FileIgnoreController` (refreshed periodically, not per-request) + - A cached `VisibleCodeTracker` + - A shared `AutocompleteTelemetry` instance + - The `AbortController` from Phase 2 +2. `KiloProvider` instantiates this once and delegates to it. + +**Estimated effort:** 0.5 days + +### Phase 4: Fix @-mention edge cases + +1. Fix `syncMentionedPaths` to use regex with boundary checks and longest-first processing. +2. Refactor `selectMentionFile` to call `buildTextAfterMentionSelect`. +3. Add scroll sync after programmatic scroll changes. + +**Estimated effort:** 0.5 days + +### Phase 5: Fix telemetry correlation + +1. Use the shared `AutocompleteTelemetry` from Phase 3 for both request and acceptance tracking. +2. Remove the orphaned singleton in `handleChatCompletionAccepted.ts`. + +**Estimated effort:** 0.5 days + +--- + +## Out of Scope + +- Adding /slash command support (separate feature, not a bug fix) +- Changing the highlight overlay rendering technique (current approach works) +- Changing from SolidJS to React (not practical) + +## Success Criteria + +- Ghost text never appears when @-mention dropdown is open +- Ghost text survives blur/focus without re-requesting +- Only one LLM request is in-flight at a time +- No per-request object creation overhead +- `syncMentionedPaths` correctly handles prefix-overlapping paths +- Telemetry correctly correlates requests with acceptances From 7a502a6f008e25b28cba3136885cdb6fcc546ccd Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Thu, 19 Mar 2026 11:24:50 +0100 Subject: [PATCH 03/45] refactor(vscode): extract ghost text logic into useGhostText hook Centralize all ghost text (AI autocomplete) state management into a dedicated hook with a single syncVisibility function. Previously the show/hide logic was scattered across handleInput, handleKeyDown, handleFocus, handleBlur, and the message listener in PromptInput.tsx. The new useGhostText hook owns: - Ghost text signal and request counter - Debounce timer and request scheduling - Single sync() function as source of truth for visibility - Blur/focus preservation of saved suggestions - Mention-open suppression --- .../src/components/chat/PromptInput.tsx | 121 +++------- .../webview-ui/src/hooks/useGhostText.ts | 213 ++++++++++++++++++ 2 files changed, 250 insertions(+), 84 deletions(-) create mode 100644 packages/kilo-vscode/webview-ui/src/hooks/useGhostText.ts 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 03930a5c801..a44bae05a40 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -3,7 +3,7 @@ * Text input with send/abort buttons, ghost-text autocomplete, and @ file mention support */ -import { Component, createSignal, createEffect, on, For, Index, onCleanup, onMount, Show, untrack } from "solid-js" +import { Component, createSignal, createEffect, on, For, Index, onCleanup, Show, untrack } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Dialog } from "@kilocode/kilo-ui/dialog" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -20,6 +20,7 @@ import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" import { useSlashCommand } from "../../hooks/useSlashCommand" +import { useGhostText } from "../../hooks/useGhostText" import { useImageAttachments } from "../../hooks/useImageAttachments" import { usePromptHistory } from "../../hooks/usePromptHistory" import { WandSparkles } from "@kilocode/kilo-ui/lucide" @@ -27,9 +28,6 @@ import { fileName, dirName, buildHighlightSegments, atEnd } from "./prompt-input import type { ReviewComment, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" -const AUTOCOMPLETE_DEBOUNCE_MS = 500 -const MIN_TEXT_LENGTH = 3 - // Per-session input text storage (module-level so it survives remounts) const drafts = new Map() const reviewDrafts = new Map() @@ -63,13 +61,13 @@ export const PromptInput: Component = (props) => { const sessionKey = () => session.currentSessionID() ?? "__new__" const [text, setText] = createSignal("") - const [ghostText, setGhostText] = createSignal("") const [reviewComments, setReviewComments] = createSignal([]) - const [chatAutocompleteEnabled, setChatAutocompleteEnabled] = createSignal(false) const [enhancing, setEnhancing] = createSignal(false) let enhanceCounter = 0 let preEnhanceText: string | null = null + const ghost = useGhostText(vscode, text, () => server.isConnected()) + const replaceReviewComments = (next: ReviewComment[]) => { setReviewComments(next) if (next.length === 0) { @@ -137,8 +135,6 @@ export const PromptInput: Component = (props) => { let highlightRef: HTMLDivElement | undefined let dropdownRef: HTMLDivElement | undefined let slashDropdownRef: HTMLDivElement | undefined - let debounceTimer: ReturnType | undefined - let requestCounter = 0 // Save/restore input text when switching sessions. // Uses `on()` to track only sessionKey — avoids re-running on every keystroke. createEffect( @@ -152,7 +148,7 @@ export const PromptInput: Component = (props) => { const draft = drafts.get(key) ?? "" const pending = reviewDrafts.get(key) ?? [] setText(draft) - setGhostText("") + ghost.invalidate() setReviewComments(pending) history.reset() if (textareaRef) { @@ -228,23 +224,9 @@ export const PromptInput: Component = (props) => { } const unsubscribe = vscode.onMessage((message) => { - if (message.type === "chatCompletionResult") { - const result = message as { type: "chatCompletionResult"; text: string; requestId: string } - if (result.requestId !== `chat-ac-${requestCounter}`) return - if (result.text && isAtEnd()) { - setGhostText(result.text) - return - } - setGhostText("") - } - - if (message.type === "autocompleteSettingsLoaded") { - setChatAutocompleteEnabled(message.settings.enableChatAutocomplete) - } - if (message.type === "setChatBoxMessage") { setText(message.text) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = message.text adjustHeight() @@ -256,12 +238,13 @@ export const PromptInput: Component = (props) => { const separator = current && !current.endsWith("\n") ? "\n\n" : "" const next = current + separator + message.text setText(next) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = next adjustHeight() textareaRef.focus() textareaRef.scrollTop = textareaRef.scrollHeight + syncHighlightScroll() } } @@ -285,7 +268,7 @@ export const PromptInput: Component = (props) => { if (target === sessionKey() && !text().trim() && imageAttach.images().length === 0) { if (failed.text) { setText(failed.text) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = failed.text adjustHeight() @@ -312,7 +295,7 @@ export const PromptInput: Component = (props) => { const result = message as import("../../types/messages").EnhancePromptResultMessage if (result.requestId === `enhance-${enhanceCounter}`) { setText(result.text) - setGhostText("") + ghost.dismiss() setEnhancing(false) if (textareaRef) { textareaRef.value = result.text @@ -330,10 +313,6 @@ export const PromptInput: Component = (props) => { } }) - onMount(() => { - vscode.postMessage({ type: "requestAutocompleteSettings" }) - }) - onCleanup(() => { // Persist current draft before unmounting const current = text() @@ -342,39 +321,22 @@ export const PromptInput: Component = (props) => { if (pending.length > 0) reviewDrafts.set(sessionKey(), pending) else reviewDrafts.delete(sessionKey()) unsubscribe() - if (debounceTimer) clearTimeout(debounceTimer) }) - const requestAutocomplete = (val: string) => { - if (val.length < MIN_TEXT_LENGTH || isDisabled() || !chatAutocompleteEnabled() || !isAtEnd()) { - setGhostText("") - requestCounter++ - return - } - requestCounter++ - vscode.postMessage({ type: "requestChatCompletion", text: val, requestId: `chat-ac-${requestCounter}` }) - } - const acceptSuggestion = () => { - const suggestion = ghostText() - if (!suggestion) return + const result = ghost.accept() + if (!result) return - const newText = text() + suggestion - setText(newText) - setGhostText("") - vscode.postMessage({ type: "chatCompletionAccepted", suggestionLength: suggestion.length }) + const val = text() + result.text + setText(val) if (textareaRef) { - textareaRef.value = newText + textareaRef.value = val adjustHeight() } } - const dismissSuggestion = () => setGhostText("") - const clearIfNotAtEnd = () => { - if (isAtEnd()) return - setGhostText("") - } + const syncGhost = () => ghost.sync(textareaRef) const scrollToActiveItem = () => { if (!dropdownRef) return @@ -419,21 +381,13 @@ export const PromptInput: Component = (props) => { setText(val) preEnhanceText = null adjustHeight() - setGhostText("") syncHighlightScroll() history.reset() slash.onInput(val, target.selectionStart ?? val.length) mention.onInput(val, target.selectionStart ?? val.length) - - if (slash.show() || mention.showMention()) { - setGhostText("") - if (debounceTimer) clearTimeout(debounceTimer) - return - } - - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => requestAutocomplete(val), AUTOCOMPLETE_DEBOUNCE_MS) + ghost.setMentionOpen(slash.show() || mention.showMention()) + ghost.scheduleRequest(val, textareaRef) } const handleKeyDown = (e: KeyboardEvent) => { @@ -443,7 +397,7 @@ export const PromptInput: Component = (props) => { const restored = preEnhanceText preEnhanceText = null setText(restored) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = restored adjustHeight() @@ -452,13 +406,13 @@ export const PromptInput: Component = (props) => { } if (slash.onKeyDown(e, textareaRef, setText, adjustHeight)) { - setGhostText("") + ghost.setMentionOpen(slash.show()) queueMicrotask(scrollToActiveSlashItem) return } if (mention.onKeyDown(e, textareaRef, setText, adjustHeight)) { - setGhostText("") + ghost.setMentionOpen(mention.showMention()) queueMicrotask(scrollToActiveItem) return } @@ -474,7 +428,7 @@ export const PromptInput: Component = (props) => { if (entry !== null) { e.preventDefault() setText(entry) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = entry adjustHeight() @@ -485,22 +439,22 @@ export const PromptInput: Component = (props) => { } } - if (e.key === "Tab" && ghostText()) { + if (e.key === "Tab" && ghost.text()) { if (!isAtEnd()) return e.preventDefault() acceptSuggestion() return } - if (e.key === "ArrowRight" && ghostText()) { + if (e.key === "ArrowRight" && ghost.text()) { if (!isAtEnd()) return e.preventDefault() acceptSuggestion() return } - if (e.key === "Escape" && ghostText()) { + if (e.key === "Escape" && ghost.text()) { e.preventDefault() e.stopPropagation() - dismissSuggestion() + ghost.dismiss() return } if (e.key === "Escape" && isBusy()) { @@ -511,7 +465,7 @@ export const PromptInput: Component = (props) => { } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() - dismissSuggestion() + ghost.dismiss() handleSend() } } @@ -524,7 +478,7 @@ export const PromptInput: Component = (props) => { if (!draft) { const description = language.t("prompt.action.enhanceDescription") setText(description) - setGhostText("") + ghost.dismiss() if (textareaRef) { textareaRef.value = description adjustHeight() @@ -553,10 +507,9 @@ export const PromptInput: Component = (props) => { // Client-side slash command — runs locally without a backend round-trip if (matched?.action) { setText("") - setGhostText("") + ghost.invalidate() clearReviewComments() imageAttach.clear() - if (debounceTimer) clearTimeout(debounceTimer) mention.closeMention() slash.close() drafts.delete(sessionKey()) @@ -589,12 +542,10 @@ export const PromptInput: Component = (props) => { history.append(draft) history.reset() - requestCounter++ + ghost.invalidate() setText("") - setGhostText("") clearReviewComments() imageAttach.clear() - if (debounceTimer) clearTimeout(debounceTimer) mention.closeMention() slash.close() drafts.delete(sessionKey()) @@ -783,8 +734,8 @@ export const PromptInput: Component = (props) => { )} - - {ghostText()} + + {ghost.text()}