diff --git a/package-lock.json b/package-lock.json
index 28e741c..fd332a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,6 @@
"@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.36.5",
"@clerk/themes": "^2.4.55",
- "@codemirror/commands": "^6.10.2",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
@@ -21,7 +20,6 @@
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.39.15",
"@hookform/resolvers": "^5.2.2",
"@inngest/middleware-sentry": "^0.1.3",
"@mendable/firecrawl-js": "^1.21.1",
@@ -40,6 +38,7 @@
"embla-carousel-react": "^8.6.0",
"inngest": "^3.48.1",
"input-otp": "^1.4.2",
+ "ky": "^1.14.3",
"lucide-react": "^0.575.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
@@ -13627,6 +13626,18 @@
"node": ">=6"
}
},
+ "node_modules/ky": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
+ "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/ky?sponsor=1"
+ }
+ },
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
diff --git a/package.json b/package.json
index 44ec17d..959483a 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,6 @@
"@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.36.5",
"@clerk/themes": "^2.4.55",
- "@codemirror/commands": "^6.10.2",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
@@ -22,7 +21,6 @@
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.39.15",
"@hookform/resolvers": "^5.2.2",
"@inngest/middleware-sentry": "^0.1.3",
"@mendable/firecrawl-js": "^1.21.1",
@@ -41,6 +39,7 @@
"embla-carousel-react": "^8.6.0",
"inngest": "^3.48.1",
"input-otp": "^1.4.2",
+ "ky": "^1.14.3",
"lucide-react": "^0.575.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
diff --git a/src/app/api/quick-edit/route.ts b/src/app/api/quick-edit/route.ts
new file mode 100644
index 0000000..3355ff9
--- /dev/null
+++ b/src/app/api/quick-edit/route.ts
@@ -0,0 +1,113 @@
+import { z } from "zod";
+import { generateText, Output } from "ai";
+import { NextResponse } from "next/server";
+import { auth } from "@clerk/nextjs/server";
+import { anthropic } from "@ai-sdk/anthropic";
+import { firecrawl } from "@/lib/firecrawl";
+
+const quickEditSchema = z.object({
+ editedCode: z
+ .string()
+ .describe(
+ "The edited version of the selected code based on the instruction"
+ ),
+});
+
+const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
+
+const QUICK_EDIT_PROMPT = `You are a code editing assistant. Edit the selected code based on the user's instruction.
+
+
+
+{selectedCode}
+
+
+{fullCode}
+
+
+
+{documentation}
+
+
+{instruction}
+
+
+
+Return ONLY the edited version of the selected code.
+Maintain the same indentation level as the original.
+Do not include any explanations or comments unless requested.
+If the instruction is unclear or cannot be applied, return the original code unchanged.
+`;
+
+export async function POST(request: Request) {
+ try {
+ const { userId } = await auth();
+ const { selectedCode, fullCode, instruction } = await request.json();
+
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 400 });
+ }
+
+ if (!selectedCode) {
+ return NextResponse.json(
+ { error: "Selected code is required" },
+ { status: 400 }
+ );
+ }
+
+ if (!instruction) {
+ return NextResponse.json(
+ { error: "Instruction is required" },
+ { status: 400 }
+ );
+ }
+
+ const urls: string[] = instruction.match(URL_REGEX) || [];
+ let documentationContext = "";
+
+ if (urls.length > 0) {
+ const scrapedResults = await Promise.all(
+ urls.map(async (url) => {
+ try {
+ const result = await firecrawl.scrapeUrl(url, {
+ formats: ["markdown"],
+ });
+
+ if (result.success && result.markdown) {
+ return `\n${result.markdown}\n`;
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+ })
+ );
+
+ const validResults = scrapedResults.filter(Boolean);
+
+ if (validResults.length > 0) {
+ documentationContext = `\n${validResults.join("\n\n")}\n`;
+ }
+ }
+
+ const prompt = QUICK_EDIT_PROMPT.replace("{selectedCode}", selectedCode)
+ .replace("{fullCode}", fullCode || "")
+ .replace("{instruction}", instruction)
+ .replace("{documentation}", documentationContext);
+
+ const { output } = await generateText({
+ model: anthropic("claude-haiku-4-5-20251001"),
+ output: Output.object({ schema: quickEditSchema }),
+ prompt,
+ });
+
+ return NextResponse.json({ editedCode: output.editedCode });
+ } catch (error) {
+ console.error("Edit error:", error);
+ return NextResponse.json(
+ { error: "Failed to generate edit" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/suggestion/route.ts b/src/app/api/suggestion/route.ts
new file mode 100644
index 0000000..002b16a
--- /dev/null
+++ b/src/app/api/suggestion/route.ts
@@ -0,0 +1,92 @@
+import { generateText, Output } from "ai";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { anthropic } from "@ai-sdk/anthropic";
+import { auth } from "@clerk/nextjs/server";
+// import { google } from "@ai-sdk/google";
+
+const suggestionSchema = z.object({
+ suggestion: z
+ .string()
+ .describe(
+ "The code to insert at cursor, or empty string if no complete needed"
+ ),
+});
+
+const SUGGESTION_PROMPT = `You are a code suggestion assistant.
+
+
+{fileName}
+
+{previousLines}
+
+{currentLine}
+{textBeforeCursor}
+{textAfterCursor}
+
+{nextLines}
+
+
+{code}
+
+
+
+
+Follow these steps IN ORDER:
+
+1. First, look at next_lines. If next_lines contains ANY code, check if it continues from where the cursor is. If it does, return empty string immediately - the code is already written.
+
+2. Check if before_cursor ends with a complete statement (;, }, )). If yes, return empty string.
+
+3. Only if steps 1 and 2 don't apply: suggest what should be typed at the cursor position, using context from full_code.
+
+Your suggestion is inserted immediately after the cursor, so never suggest code that's already in the file.
+`;
+
+export async function POST(request: Request) {
+ try {
+ const { userId } = await auth();
+
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
+ }
+
+ const {
+ fileName,
+ code,
+ currentLine,
+ previousLines,
+ textBeforeCursor,
+ textAfterCursor,
+ nextLines,
+ lineNumber,
+ } = await request.json();
+
+ if (!code) {
+ return NextResponse.json({ error: "Code is required" }, { status: 400 });
+ }
+
+ const prompt = SUGGESTION_PROMPT.replace("{fileName}", fileName)
+ .replace("{code}", code)
+ .replace("{currentLine}", currentLine)
+ .replace("{previousLines}", previousLines || "")
+ .replace("{textBeforeCursor}", textBeforeCursor)
+ .replace("{textAfterCursor}", textAfterCursor)
+ .replace("{nextLines}", nextLines || "")
+ .replace("{lineNumber}", lineNumber.toString());
+
+ const { output } = await generateText({
+ model: anthropic("claude-haiku-4-5-20251001"),
+ output: Output.object({ schema: suggestionSchema }),
+ prompt,
+ });
+
+ return NextResponse.json({ suggestion: output.suggestion });
+ } catch (error) {
+ console.error("Suggestion error: ", error);
+ return NextResponse.json(
+ { error: "Failed to generate suggestion" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 66e8dfb..f0b5954 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -119,7 +119,7 @@
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground select-none;
+ @apply bg-background text-foreground;
}
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e2cda74..d7d74df 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Inter } from "next/font/google";
+import { Toaster } from "sonner";
import { Providers } from "@/components/providers";
import "./globals.css";
@@ -29,7 +30,10 @@ export default function RootLayout({
return (
- {children}
+
+ {children}
+
+
);
diff --git a/src/features/editor/components/code-editor.tsx b/src/features/editor/components/code-editor.tsx
index a046c4b..fd52d63 100644
--- a/src/features/editor/components/code-editor.tsx
+++ b/src/features/editor/components/code-editor.tsx
@@ -8,6 +8,9 @@ import { keymap } from "@codemirror/view";
import { minimap } from "../extensions/minimap";
import { indentationMarkers } from "@replit/codemirror-indentation-markers";
import { customSetup } from "../extensions/custom-setup";
+import { suggestion } from "../extensions/suggestion";
+import { quickEdit } from "../extensions/quick-edit";
+import { selectionTooltip } from "../extensions/selection-tooltip";
interface Props {
fileName: string;
@@ -36,11 +39,14 @@ export const CodeEditor = ({
extensions: [
oneDark,
customTheme,
- languageExtension,
customSetup,
+ languageExtension,
+ suggestion(fileName),
+ quickEdit(fileName),
+ selectionTooltip(),
+ keymap.of([indentWithTab]),
minimap(),
indentationMarkers(),
- keymap.of([indentWithTab]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange?.(update.state.doc.toString());
diff --git a/src/features/editor/components/editor-view.tsx b/src/features/editor/components/editor-view.tsx
index 4f6a94d..462cd42 100644
--- a/src/features/editor/components/editor-view.tsx
+++ b/src/features/editor/components/editor-view.tsx
@@ -6,7 +6,7 @@ import { useFile } from "@/features/projects/hooks/use-files";
import Image from "next/image";
import { CodeEditor } from "./code-editor";
import { useUpdateFile } from "@/features/projects/hooks/use-files";
-import { useRef } from "react";
+import { useEffect, useRef } from "react";
const DEBOUNCE_MS = 1500;
@@ -19,6 +19,15 @@ export const EditorView = ({ projectId }: { projectId: Id<"projects"> }) => {
const isActiveFileBinary = activeFile && activeFile.storageId;
const isActiveFileText = activeFile && !activeFile.storageId;
+ // Clean-up pending debounced updates on unmount or file change
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [activeTabId]);
+
return (
diff --git a/src/features/editor/extensions/quick-edit/fetcher.ts b/src/features/editor/extensions/quick-edit/fetcher.ts
new file mode 100644
index 0000000..fa795ff
--- /dev/null
+++ b/src/features/editor/extensions/quick-edit/fetcher.ts
@@ -0,0 +1,44 @@
+import ky from "ky";
+import { z } from "zod";
+import { toast } from "sonner";
+
+const editRequestSchema = z.object({
+ selectedCode: z.string(),
+ fullCode: z.string(),
+ instruction: z.string(),
+});
+
+const editResponseSchema = z.object({
+ editedCode: z.string(),
+});
+
+type EditRequest = z.infer;
+type EditResponse = z.infer;
+
+export const fetcher = async (
+ payload: EditRequest,
+ signal: AbortSignal
+): Promise => {
+ try {
+ const validatedPayload = editRequestSchema.parse(payload);
+
+ const response = await ky
+ .post("/api/quick-edit", {
+ json: validatedPayload,
+ signal,
+ timeout: 30_000,
+ retry: 0,
+ })
+ .json();
+
+ const validatedResponse = editResponseSchema.parse(response);
+
+ return validatedResponse.editedCode || null;
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return null;
+ }
+ toast.error("Failed to fetch AI quick edit");
+ return null;
+ }
+};
diff --git a/src/features/editor/extensions/quick-edit/index.ts b/src/features/editor/extensions/quick-edit/index.ts
new file mode 100644
index 0000000..70d26bb
--- /dev/null
+++ b/src/features/editor/extensions/quick-edit/index.ts
@@ -0,0 +1,210 @@
+import {
+ Decoration,
+ DecorationSet,
+ EditorView,
+ Tooltip,
+ ViewPlugin,
+ ViewUpdate,
+ WidgetType,
+ keymap,
+ showTooltip,
+} from "@codemirror/view";
+import { EditorState, StateEffect, StateField } from "@codemirror/state";
+
+import { fetcher } from "./fetcher";
+
+export const showQuickEditEffect = StateEffect.define();
+
+let editorView: EditorView | null = null;
+let currentAbortController: AbortController | null = null;
+
+export const quickEditState = StateField.define({
+ create() {
+ return false;
+ },
+ update(value, transaction) {
+ for (const effect of transaction.effects) {
+ if (effect.is(showQuickEditEffect)) {
+ return effect.value;
+ }
+ }
+ if (transaction.selection) {
+ const selection = transaction.state.selection.main;
+ if (selection.empty) {
+ return false;
+ }
+ }
+
+ return value;
+ },
+});
+
+const createQuickEditTooltip = (state: EditorState): readonly Tooltip[] => {
+ const selection = state.selection.main;
+
+ if (selection.empty) {
+ return [];
+ }
+
+ const isQuickEditActive = state.field(quickEditState);
+ if (!isQuickEditActive) {
+ return [];
+ }
+
+ return [
+ {
+ pos: selection.to,
+ above: false,
+ strictSide: false,
+ create() {
+ const dom = document.createElement("div");
+ dom.className =
+ "bg-popover text-popover-foreground z-50 rounded-sm border border-input p-2 shadow-md flex flex-col gap-2 text-sm";
+
+ const form = document.createElement("form");
+ form.className = "flex flex-col gap-2";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "Edit selected code";
+ input.className =
+ "bg-transparent border-none outline-none px-2 py-1 font-sans w-100";
+ input.autofocus = true;
+
+ const buttonContainer = document.createElement("div");
+ buttonContainer.className = "flex items-center justify-between gap-2";
+
+ const cancelButton = document.createElement("button");
+ cancelButton.type = "button";
+ cancelButton.textContent = "Cancel";
+ cancelButton.className =
+ "font-sans p-1 px-2 text-muted-foreground hover:text-foreground hover:bg-foreground/10 rounded-sm";
+ cancelButton.onclick = () => {
+ if (currentAbortController) {
+ currentAbortController.abort();
+ currentAbortController = null;
+ }
+ if (editorView) {
+ editorView.dispatch({
+ effects: showQuickEditEffect.of(false),
+ });
+ }
+ };
+
+ const submitButton = document.createElement("button");
+ submitButton.type = "submit";
+ submitButton.textContent = "Submit";
+ submitButton.className =
+ "font-sans p-1 px-2 text-muted-foreground hover:text-foreground hover:bg-foreground/10 rounded-sm";
+
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+
+ if (!editorView) return;
+
+ const instruction = input.value.trim();
+ if (!instruction) return;
+
+ const selection = editorView.state.selection.main;
+ const selectedCode = editorView.state.doc.sliceString(
+ selection.from,
+ selection.to
+ );
+ const fullCode = editorView.state.doc.toString();
+
+ submitButton.disabled = true;
+ submitButton.textContent = "Editing...";
+
+ currentAbortController = new AbortController();
+ const editedCode = await fetcher(
+ {
+ selectedCode,
+ fullCode,
+ instruction,
+ },
+ currentAbortController.signal
+ );
+
+ if (editedCode) {
+ editorView.dispatch({
+ changes: {
+ from: selection.from,
+ to: selection.to,
+ insert: editedCode,
+ },
+ selection: { anchor: selection.from + editedCode.length },
+ effects: showQuickEditEffect.of(false),
+ });
+ } else {
+ submitButton.disabled = false;
+ submitButton.textContent = "Submit";
+ }
+
+ currentAbortController = null;
+ };
+
+ buttonContainer.appendChild(cancelButton);
+ buttonContainer.appendChild(submitButton);
+
+ form.appendChild(input);
+ form.appendChild(buttonContainer);
+
+ dom.appendChild(form);
+
+ setTimeout(() => {
+ input.focus();
+ }, 0);
+
+ return { dom };
+ },
+ },
+ ];
+};
+
+const quickEditTooltipField = StateField.define({
+ create(state) {
+ return createQuickEditTooltip(state);
+ },
+
+ update(tooltips, transaction) {
+ if (transaction.docChanged || transaction.selection) {
+ return createQuickEditTooltip(transaction.state);
+ }
+ for (const effect of transaction.effects) {
+ if (effect.is(showQuickEditEffect)) {
+ return createQuickEditTooltip(transaction.state);
+ }
+ }
+ return tooltips;
+ },
+ provide: (field) =>
+ showTooltip.computeN([field], (state) => state.field(field)),
+});
+
+const quickEditKeymap = keymap.of([
+ {
+ key: "Mod-k",
+ run: (view) => {
+ const selection = view.state.selection.main;
+ if (selection.empty) {
+ return false;
+ }
+
+ view.dispatch({
+ effects: showQuickEditEffect.of(true),
+ });
+ return true;
+ },
+ },
+]);
+
+const captureViewExtension = EditorView.updateListener.of((update) => {
+ editorView = update.view;
+});
+
+export const quickEdit = (fileName: string) => [
+ quickEditState,
+ quickEditTooltipField,
+ quickEditKeymap,
+ captureViewExtension,
+];
diff --git a/src/features/editor/extensions/selection-tooltip.ts b/src/features/editor/extensions/selection-tooltip.ts
new file mode 100644
index 0000000..81b0985
--- /dev/null
+++ b/src/features/editor/extensions/selection-tooltip.ts
@@ -0,0 +1,93 @@
+import { Tooltip, showTooltip, EditorView } from "@codemirror/view";
+import { StateField, EditorState } from "@codemirror/state";
+import { showQuickEditEffect, quickEditState } from "./quick-edit";
+
+let editorView: EditorView | null = null;
+
+const createTooltipForSelection = (state: EditorState): readonly Tooltip[] => {
+ const selection = state.selection.main;
+
+ if (selection.empty) {
+ return [];
+ }
+
+ const isQuickEditActive = state.field(quickEditState);
+ if (isQuickEditActive) {
+ return [];
+ }
+
+ return [
+ {
+ pos: selection.to,
+ above: false,
+ strictSide: false,
+ create() {
+ const dom = document.createElement("div");
+ dom.className =
+ "bg-popover text-popover-foreground z-50 rounded-sm border border-input p-1 shadow-md flex items-center gap-2 text-sm";
+
+ const addToChatButton = document.createElement("button");
+ addToChatButton.textContent = "Add to Chat";
+ addToChatButton.className =
+ "font-sans p-1 px-2 hover:bg-foreground/10 rounded-sm";
+
+ const quickEditButton = document.createElement("button");
+ quickEditButton.className =
+ "font-sans p-1 px-2 hover:bg-foreground/10 rounded-sm flex items-center gap-1";
+
+ const quickEditButtonText = document.createElement("span");
+ quickEditButtonText.textContent = "Quick Edit";
+
+ const quickEditButtonShortcut = document.createElement("span");
+ quickEditButtonShortcut.textContent = "⌘K";
+ quickEditButtonShortcut.className = "text-sm opacity-60";
+
+ quickEditButton.appendChild(quickEditButtonText);
+ quickEditButton.appendChild(quickEditButtonShortcut);
+
+ quickEditButton.onclick = () => {
+ if (editorView) {
+ editorView.dispatch({
+ effects: showQuickEditEffect.of(true),
+ });
+ }
+ };
+
+ dom.appendChild(addToChatButton);
+ dom.appendChild(quickEditButton);
+
+ return { dom };
+ },
+ },
+ ];
+};
+
+const selectionTooltipField = StateField.define({
+ create(state) {
+ return createTooltipForSelection(state);
+ },
+
+ update(tooltips, transaction) {
+ if (transaction.docChanged || transaction.selection) {
+ return createTooltipForSelection(transaction.state);
+ }
+ for (const effect of transaction.effects) {
+ if (effect.is(showQuickEditEffect)) {
+ return createTooltipForSelection(transaction.state);
+ }
+ }
+ return tooltips;
+ },
+
+ provide: (field) =>
+ showTooltip.computeN([field], (state) => state.field(field)),
+});
+
+const captureViewExtension = EditorView.updateListener.of((update) => {
+ editorView = update.view;
+});
+
+export const selectionTooltip = () => [
+ selectionTooltipField,
+ captureViewExtension,
+];
diff --git a/src/features/editor/extensions/suggestion/fetcher.ts b/src/features/editor/extensions/suggestion/fetcher.ts
new file mode 100644
index 0000000..e7fc874
--- /dev/null
+++ b/src/features/editor/extensions/suggestion/fetcher.ts
@@ -0,0 +1,49 @@
+import ky from "ky";
+import { z } from "zod";
+import { toast } from "sonner";
+
+const suggestionRequestSchema = z.object({
+ fileName: z.string(),
+ code: z.string(),
+ currentLine: z.string(),
+ previousLines: z.string(),
+ textBeforeCursor: z.string(),
+ textAfterCursor: z.string(),
+ nextLines: z.string(),
+ lineNumber: z.number(),
+});
+
+const suggestionResponseSchema = z.object({
+ suggestion: z.string(),
+});
+
+type SuggestionRequest = z.infer;
+type SuggestionResponse = z.infer;
+
+export const fetcher = async (
+ payload: SuggestionRequest,
+ signal: AbortSignal
+): Promise => {
+ try {
+ const validatedPayload = suggestionRequestSchema.parse(payload);
+
+ const response = await ky
+ .post("/api/suggestion", {
+ json: validatedPayload,
+ signal,
+ timeout: 10_000,
+ retry: 0,
+ })
+ .json();
+
+ const validatedResponse = suggestionResponseSchema.parse(response);
+
+ return validatedResponse.suggestion || null;
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return null;
+ }
+ toast.error("Failed to fetch AI completion");
+ return null;
+ }
+};
diff --git a/src/features/editor/extensions/suggestion/index.ts b/src/features/editor/extensions/suggestion/index.ts
new file mode 100644
index 0000000..bda1862
--- /dev/null
+++ b/src/features/editor/extensions/suggestion/index.ts
@@ -0,0 +1,234 @@
+import {
+ Decoration,
+ DecorationSet,
+ EditorView,
+ ViewPlugin,
+ ViewUpdate,
+ WidgetType,
+ keymap,
+} from "@codemirror/view";
+import { StateEffect, StateField } from "@codemirror/state";
+
+import { fetcher } from "./fetcher";
+
+// StateEffect: A way to send "messages" to update state.
+// We define one effect type for setting the suggestion text.
+const setSuggestionEffect = StateEffect.define();
+
+// StateField: Holds our suggestion state in the editor.
+// - create(): Returns the initial value when the editor loads
+// - update(): Called on every transaction (keystroke, etc.) to potentially update the value
+const suggestionState = StateField.define({
+ create() {
+ return null;
+ },
+ update(value, transaction) {
+ // Check each effect in this transaction
+ // If we find our setSuggestionEffect, return its new value
+ // Otherwise, keep the current value unchanged
+ for (const effect of transaction.effects) {
+ if (effect.is(setSuggestionEffect)) {
+ return effect.value;
+ }
+ }
+ return value;
+ },
+});
+
+// WidgetType: Creates custom DOM elements to display in the editor.
+// toDOM() is called by CodeMirror to create the actual HTML element.
+class SuggestionWidget extends WidgetType {
+ constructor(readonly text: string) {
+ super();
+ }
+
+ toDOM() {
+ const span = document.createElement("span");
+ span.textContent = this.text;
+ span.style.opacity = "0.4";
+ span.style.pointerEvents = "none";
+ return span;
+ }
+}
+
+let debounceTimer: number | null = null;
+let isWaitingForSuggestion = false;
+const DEBOUNCE_DELAY = 300;
+
+let currentAbortController: AbortController | null = null;
+
+const generateFakeSuggestion = (textBeforeCursor: string): string | null => {
+ const trimmed = textBeforeCursor.trimEnd();
+ if (trimmed.endsWith("const")) return " myVariable = ";
+ if (trimmed.endsWith("function")) return " myFunction() {\n \n}";
+ if (trimmed.endsWith("console.")) return "log()";
+ if (trimmed.endsWith("return")) return "null";
+ return null;
+};
+
+const generatePayload = (view: EditorView, fileName: string) => {
+ const code = view.state.doc.toString();
+ if (!code || code.trim().length === 0) return null;
+
+ const cursorPosition = view.state.selection.main.head;
+ const currentLine = view.state.doc.lineAt(cursorPosition);
+ const cursorInLine = cursorPosition - currentLine.from;
+
+ const previousLines: string[] = [];
+ const previousLinesToFetch = Math.min(5, currentLine.number - 1);
+ for (let i = previousLinesToFetch; i >= 1; i--) {
+ previousLines.push(view.state.doc.line(currentLine.number - i).text);
+ }
+
+ const nextLines: string[] = [];
+ const totalLines = view.state.doc.lines;
+ const linesToFetch = Math.min(5, totalLines - currentLine.number);
+ for (let i = 1; i <= linesToFetch; i++) {
+ nextLines.push(view.state.doc.line(currentLine.number + i).text);
+ }
+
+ return {
+ fileName,
+ code,
+ currentLine: currentLine.text,
+ previousLines: previousLines.join("\n"),
+ textBeforeCursor: currentLine.text.slice(0, cursorInLine),
+ textAfterCursor: currentLine.text.slice(cursorInLine),
+ nextLines: nextLines.join("\n"),
+ lineNumber: currentLine.number,
+ };
+};
+
+const createDebouncePlugin = (fileName: string) => {
+ return ViewPlugin.fromClass(
+ class {
+ constructor(view: EditorView) {
+ this.triggerSuggestion(view);
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.selectionSet) {
+ this.triggerSuggestion(update.view);
+ }
+ }
+
+ triggerSuggestion(view: EditorView) {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+
+ if (currentAbortController !== null) {
+ currentAbortController.abort();
+ }
+
+ isWaitingForSuggestion = true;
+
+ debounceTimer = window.setTimeout(async () => {
+ const payload = generatePayload(view, fileName);
+ if (!payload) {
+ isWaitingForSuggestion = false;
+ view.dispatch({ effects: setSuggestionEffect.of(null) });
+ return;
+ }
+ currentAbortController = new AbortController();
+ const suggestion = await fetcher(
+ payload,
+ currentAbortController.signal
+ );
+
+ isWaitingForSuggestion = false;
+ view.dispatch({
+ effects: setSuggestionEffect.of(suggestion),
+ });
+ }, DEBOUNCE_DELAY);
+ }
+
+ destroy() {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+
+ if (currentAbortController !== null) {
+ currentAbortController.abort();
+ }
+ }
+ }
+ );
+};
+
+const renderPlugin = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = this.build(view);
+ }
+
+ update(update: ViewUpdate) {
+ // Rebuild decorations if doc changed, cursor moved, or suggestion changed
+ const suggestionChanged = update.transactions.some((transaction) => {
+ return transaction.effects.some((effect) => {
+ return effect.is(setSuggestionEffect);
+ });
+ });
+
+ // Rebuild decorations if doc changed, cursor moved, or suggestion changed
+ const shouldRebuild =
+ update.docChanged || update.selectionSet || suggestionChanged;
+
+ if (shouldRebuild) {
+ this.decorations = this.build(update.view);
+ }
+ }
+
+ build(view: EditorView) {
+ if (isWaitingForSuggestion) {
+ return Decoration.none;
+ }
+
+ // Get current suggestion from state
+ const suggestion = view.state.field(suggestionState);
+ if (!suggestion) {
+ return Decoration.none;
+ }
+
+ // Create a widget decoration at the cursor position
+ // side: 1 means the widget will be on the right side of the cursor
+ const cursor = view.state.selection.main.head;
+ return Decoration.set([
+ Decoration.widget({
+ widget: new SuggestionWidget(suggestion),
+ side: 1,
+ }).range(cursor),
+ ]);
+ }
+ },
+ { decorations: (plugin) => plugin.decorations } // Tell CodeMirror to use our decorations
+);
+
+const acceptSuggestionKeymap = keymap.of([
+ {
+ key: "Tab",
+ run: (view) => {
+ const suggestion = view.state.field(suggestionState);
+ if (!suggestion) {
+ return false; // No suggestion? Let Tab do its normal thing (indent)
+ }
+
+ const cursor = view.state.selection.main.head;
+ view.dispatch({
+ changes: { from: cursor, insert: suggestion }, // Insert the suggestion text
+ selection: { anchor: cursor + suggestion.length }, // Move cursor to end
+ effects: setSuggestionEffect.of(null), // Clear the suggestion
+ });
+ return true; // We handled Tab, don't indent
+ },
+ },
+]);
+
+export const suggestion = (fileName: string) => [
+ suggestionState, // Our state storage
+ createDebouncePlugin(fileName),
+ renderPlugin, // Renders the ghost text
+ acceptSuggestionKeymap, // Accepts the suggestion
+];