Building Copilot On The Web

Build your own web-based Code Copilot using the Monaco Editor, Next.js, Vercel AI, and Shadcn UI

Spencer Porter
15 min readApr 28, 2024
Copilot — SaaS-ified

Background

As I’ve been doing a fair bit of work in generating OpenAPI Schema for custom actions for GPTs, I wanted to create a streamlined platform for the workflow rather than 3–4 separate windows.

As my typical strategy revolves around opening ChatGPT and a text editor before starting to hack away at it, I’d need to replicate the functionality of ChatGPT as well as Copilot.

The chat portion was easy, there’s more than enough examples, and I’ve built a ton of chat interfaces, so no issue there.

The editor though — that’s new. And it wasn’t just that it’s new for me, after a fair bit of investigation it turns out there’s no real examples that are out there for it, and the existing solutions for autocomplete with ChatGPT are a bit of a red herring

“…, [S]o it should be easy to add this functionality”

Spoiler: If you think debounced search-as-you-type works, it doesn’t.

Because of just how little information was out there, I wanted to open this up as a resource for anyone else who wanted to make something similar to pass along a few of the lessons learned. Also, If you just want the code, the repo is here and I’ve done my best to add a decent amount of comments to make it readable.

Otherwise, let’s dig into it!

Setup

This project is made using:

Set Up Next.js

Begin by creating a new Next.js application using the following command. This setup includes TypeScript, Tailwind CSS, and ESLint for code quality:

npx create-next-app@latest monaco-copilot-demo --typescript --tailwind --eslint

Install Shadcn

I’ve added Shadcn UI as a baseline component library — if you’d like to do the same, initiate it with the following commands. You will be prompted to select a style and primary color scheme, along with the option to use CSS variables. Below are my configuration variables:

npx shadcn-ui@latest init
Which style would you like to use? › New York
Which color would you like to use as primary color? › Neutral
Do you want to use CSS variables for colors? › yes

Install Monaco Editor

We’ll also use the Monaco Editor, a powerful text editor used in VS Code:

npm i @monaco-editor/react

Install Vercel AI

Vercel AI will help us in managing the request/response cycle for OpenAI:

npm i ai

Other Libraries

Finally we’ll include the following utilities for functional programming, icon components, and theme switching:

npm i lodash lucide-react next-themes
npm i --save-dev @types/lodash

Initial Layout

Starting off with a basic layout for our app, including a header, theme toggle (I have a love of dark mode), and the editor.

Let’s knock out Navbar and theme toggle:

Theme Toggle Button

Create a file named components/theme-toggle.tsx and add the following TypeScript code to manage theme toggling:

import { useTheme } from "next-themes";
import * as React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";

export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [, startTransition] = React.useTransition();
const [mounted, setMounted] = useState(false);

useEffect(() => setMounted(true), []);

useEffect(() => {
if (!resolvedTheme) return;
document.documentElement.setAttribute("data-theme", resolvedTheme);
}, [resolvedTheme]);

if (!mounted) return null; // Prevent server-side rendering

const isDarkMode = theme === "dark" || resolvedTheme === "dark";

return (
<Button
variant="ghost"
size="icon"
onClick={() => {
startTransition(() => {
setTheme(isDarkMode ? "light" : "dark");
});
}}
>
{isDarkMode ? <Moon className="transition-all size-5" /> : <Sun className="transition-all size-5" />}
<span className="sr-only">Toggle theme</span>
</Button>
);
}

Navbar

Getting to the Navbar itself, we’ll install the following Shadcn components:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add separator
npx shadcn-ui@latest add tooltip

Then, create a new file components/navbar.tsx:

import { ThemeToggle } from "@/components/theme-toggle";

const Navbar = () => {
return (
<div className="flex w-full justify-between py-2 px-6">
<h2 className="text-xl font-semibold">Editor</h2>
<ThemeToggle />
</div>
);
};

export default Navbar;

ThemeProvider Setup

Create a provider to manage themes and tooltips by adding a file named components/providers.tsx:

"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";

export function ThemeProvider({ children }) {
return (
<NextThemesProvider enableSystem defaultTheme="system" disableTransitionOnChange>
<TooltipProvider>
{children}
</TooltipProvider>
</NextThemesProvider>
);
}

Main Layout

For the main layout of your application, create a file named app/layout.tsx:

import { ThemeProvider } from "@/components/providers";
import "./globals.css";

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}

Text Editor Component

Integrate Monaco Editor by creating a file components/editor/index.tsx:

"use client";

import Editor from "@monaco-editor/react";
import { useTheme } from "next-themes";
import { CircularSpinner } from "@/components/circular-spinner";

interface TextEditorProps {
// language: Specifies the programming language for the editor. It must be one of the predefined options (although you can add more if you want)
language: "javascript" | "typescript" | "python" | "java" | "c";
}

const TextEditor = ({
language,
}: TextEditorProps) => {
return (
<Editor
height="90vh"
defaultLanguage={language}
defaultValue="// start typing...
loading={<CircularSpinner useAlternativeColor />}
theme={useTheme().resolvedTheme === "dark" ? "vs-dark" : "vs"}
options={{
autoClosingBrackets: "never",
autoClosingQuotes: "never",
formatOnType: true,
formatOnPaste: true,
trimAutoWhitespace: true,
}}
/>
);
};

export default TextEditor;

Circular Loading Spinner

Create a reusable loading spinner component in components/circular-spinner.tsx:

  interface CircularSpinnerProps {
size?: number;
}

export function CircularSpinner({
size = 1, // Default size is 1em
}: CircularSpinnerProps) {
return (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full border-2 border-green-500 dark:border-green-700 border-t-green-700 dark:border-t-green-500" style={{ width: `${size}em`, height: `${size}em` }}></div>
</div>
);
}

Editor Page with Navigation Bar

Finally, set up your editor page to include the navigation bar by creating app/page.tsx:

"use client";

import dynamic from "next/dynamic";
import Navbar from "@/components/navbar";
import { Separator } from "@/components/ui/separator";

const TextEditor = dynamic(() => import("@/components/editor"), { ssr: false });

export default function Home() {
return (
<div className="h-screen">
<Navbar />
<Separator />
<div className="max-w-[1600px] h-[90vh] pb-8 pt-[21px] mx-auto md:px-8 px-4 w-full sm:items-center sm:space-y-0">
<Card className="h-full overflow-hidden">
<TextEditor language={"python"} />
</Card>
</div>
</div>
);
}

Creating the Copilot

Next up, creating the copilot itself. Let’s start off by talking quickly about what we’re trying to achieve here and a few of the limitations we’ll be working with.

At it’s most basic — we want the editor to return code suggestions based on the current user input, provided by the ChatGPT API.

The Problem

Our first thought here would be that we can just make a request to the API on every keystroke (or use a debounce function to limit the number of requests). Unfortunately, this doesn’t work —as I found out after a number of revisions and tweaks.

Why?

Responses from the OpenAI API take on average 500ms to return, and we need to provide the position of the suggestion (line number and column) to the inlineCompletions provider. The other issue is that whatever is returned by the inlineCompletions provider is the entire list of responses that will be shown. While I tweaked around with this idea for a while, I consistently ran into the same issue over and over — the responses were stale by the time they arrived and led to a frustrating UX.

My Solution

Based on the above, I actually figured I’d take a very different tact:

  1. Create a limited local FIFO cache of recent suggestions. I used a simple useState, but this could be expanded to a more performant option as well given more users. I found limiting the number of suggestions 6–10 entries to be a solid middle ground for performance and helpfulness.
  2. As a user is typing, retrieve suggestions using a 500ms interval, adding them to the above cache.
  3. Search through the cache for suggestions that were input-local (within a few characters on either side of the current placement of the cursor), and started with the same character as the most recent keystroke.
  4. Format the returned entries to update the positioning, remove irrelevant quotes and brackets, and overlap for the new text and the start of the suggestion

Implementation

To communicate with the OpenAI API, we’re using the Vercel AI library. First, create an OpenAI account and obtain an API key, which you can find detailed instructions for in the OpenAI documentation.

1. API Endpoint Setup

Create a new file at src/app/api/completion/route.ts and define the API endpoint as follows:

import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";

export const runtime = "edge";

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});

export async function POST(req: Request) {
const { messages } = await req.json();

const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-0125",
stream: true,
messages: messages,
max_tokens: 16,
temperature: 0.1,
});

const stream = OpenAIStream(response);

return new StreamingTextResponse(stream);
}

Create a .env file in the root of your project, and add a variable for OPENAI_API_KEY that includes your API key.

2. Generating the Prompt

Create a prompt generator to tailor the API requests. Add a new file at src/components/editor/prompt.ts:

export const GenerateInstructions = (language: string) => ({
content: `## Task: Code Completion

### Language: ${language}

### Instructions:
- You are a world class coding assistant.
- Given the current text, context, and the last character of the user input, provide a suggestion for code completion.
- The suggestion must be based on the current text, as well as the text before the cursor.
- This is not a conversation, so please do not ask questions or prompt for additional information.

### Notes
- NEVER INCLUDE ANY MARKDOWN IN THE RESPONSE - THIS MEANS CODEBLOCKS AS WELL.
- Never include any annotations such as "# Suggestion:" or "# Suggestions:".
- Newlines should be included after any of the following characters: "{", "[", "(", ")", "]", "}", and ",".
- Never suggest a newline after a space or newline.
- Ensure that newline suggestions follow the same indentation as the current line.
- The suggestion must start with the last character of the current user input.
- Only ever return the code snippet, do not return any markdown unless it is part of the code snippet.
- Do not return any code that is already present in the current text.
- Do not return anything that is not valid code.
- If you do not have a suggestion, return an empty string.`,
role: "system",
});

3. Handling Completions

In the editor component file, let’s bring in the useCompletion hook from Vercel AI in our index.tsx file

// Existing Imports...

// useCompletion Hook
import { useCompletion } from "ai/react";

const TextEditor = ({ language, cacheSize = 10, refreshInterval = 500 }) => {
const monaco = useMonaco();
const { completion, complete } = useCompletion({
api: "/api/completion",
});

// Rest of the code...
};

4. Setting up the Cache, Interval, and Inline Completions

I’ll include the full code for the index.tsxfile to make sure we’re up to date. Check out the comments for an explanation on how each part fits in:

"use client";

// React essentials
import { useEffect, useState, useRef, useCallback } from "react";

// Next.js theme hook for managing dark/light mode
import { useTheme } from "next-themes";

// Monaco editor imports for code editor functionality
import Editor from "@monaco-editor/react";
import { useMonaco } from "@monaco-editor/react";

// Custom hooks and components for handling AI completions and UI elements
import { useCompletion } from "ai/react";
import { CircularSpinner } from "@/components/circular-spinner"; // CircularSpinner is a custom loading spinner component
import { CompletionFormatter } from "@/components/editor/completion-formatter";
import { GenerateInstructions } from "@/components/editor/prompt";

interface TextEditorProps {
// language: Specifies the programming language for the editor. It must be one of the predefined options (although you can add more if you want)
language: "javascript" | "typescript" | "python" | "java" | "c";
cacheSize?: number;
refreshInterval?: number;
}

const TextEditor = ({
language,
cacheSize = 10, // Default cache size for suggestions
refreshInterval = 500, // Default refresh interval in milliseconds
}: TextEditorProps) => {
// Hook to access the Monaco editor instance
const monaco = useMonaco();

// Ref to store the current instance of the editor
const editorRef = useRef<any>(null);

// Refs to manage fetching and timing of suggestions
const fetchSuggestionsIntervalRef = useRef<number | undefined>(undefined);
const timeoutRef = useRef<number | undefined>(undefined);

// State to cache suggestions received from the AI completion API
const [cachedSuggestions, setCachedSuggestions] = useState<any[]>([]);

// Custom hook to manage AI completions, initialized with the API path and body content
const { completion, stop, complete } = useCompletion({
api: "/api/completion",
body: {
language: language, // Use the language prop from TextEditorProps
},
});

const debouncedSuggestions = useCallback(() => {
// Access the current model (document) of the editor
const model = monaco?.editor.getModels()[0];

if (!model || !model.getValue()) {
setCachedSuggestions([]);
return;
}

const position = editorRef.current.getPosition();
const currentLine = model.getLineContent(position.lineNumber);
const offset = model.getOffsetAt(position);
const textBeforeCursor = model
.getValue()
.substring(0, offset - currentLine.length);
const textBeforeCursorOnCurrentLine = currentLine.substring(
0,
position.column - 1,
);

if (!textBeforeCursor) return;

const messages = [
GenerateInstructions(language),
{
content: textBeforeCursor,
role: "user",
name: "TextBeforeCursor",
},
{
content: textBeforeCursorOnCurrentLine,
role: "user",
name: "TextBeforeCursorOnCurrentLine",
},
];

// Call the completion API and handle the response
complete("", {
body: {
messages,
},
})
.then((newCompletion) => {
if (newCompletion) {
// Construct a new suggestion object based on the API response
const newSuggestion = {
insertText: newCompletion,
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber:
// Calculate the number of new lines in the completion text and add it to the current line number
position.lineNumber + (newCompletion.match(/\n/g) || []).length,
// If the suggestion is on the same line, return the length of the completion text
endColumn: position.column + newCompletion.length,
},
};

// Update the cached suggestions with the new suggestion (up to the cache size limit)
// Cache size is set to 6 by default, which I found to be a good balance between performance and usability
setCachedSuggestions((prev) =>
[...prev, newSuggestion].slice(-cacheSize),
);
}
})
.catch((error) => {
console.error("error", error);
});
}, [monaco, complete, setCachedSuggestions, language, cacheSize]);

const startOrResetFetching = useCallback(() => {
// Check if the fetching interval is not already set
if (fetchSuggestionsIntervalRef.current === undefined) {
// Immediately invoke suggestions once
debouncedSuggestions();

// Set an interval to fetch suggestions every refresh interval
// (default is 500ms which seems to align will with the
// average typing speed and latency of OpenAI API calls)
fetchSuggestionsIntervalRef.current = setInterval(
debouncedSuggestions,
refreshInterval,
) as unknown as number; // Cast to number as setInterval returns a NodeJS.Timeout in Node environments
}

// Clear any previous timeout to reset the timer
clearTimeout(timeoutRef.current);

// Set a new timeout to stop fetching suggestions if no typing occurs for 2x the refresh interval
timeoutRef.current = setTimeout(() => {
if (fetchSuggestionsIntervalRef.current !== undefined) {
window.clearInterval(fetchSuggestionsIntervalRef.current);
fetchSuggestionsIntervalRef.current = undefined;
}
}, refreshInterval * 2) as unknown as number;
}, [debouncedSuggestions, refreshInterval]);

// Cleanup on component unmount
useEffect(() => {
return () => {
// Clear the interval and timeout when the component is unmounted
window.clearInterval(fetchSuggestionsIntervalRef.current);
window.clearTimeout(timeoutRef.current);
};
}, []);

// Use the editor change event to trigger fetching of suggestions
const handleEditorChange = useCallback(() => {
startOrResetFetching();
}, [startOrResetFetching]);

useEffect(() => {
if (!monaco) return;

// Register a provider for inline completions specific to the language used in the editor
const provider = monaco.languages.registerInlineCompletionsProvider(
language,
{
provideInlineCompletions: async (model, position) => {
// Filter cached suggestions to include only those that start with the current word at the cursor position
const suggestions = cachedSuggestions.filter((suggestion) =>
suggestion.insertText.startsWith(
model.getValueInRange(suggestion.range),
),
);

// Further filter suggestions to ensure they are relevant to the current cursor position within the line
const localSuggestions = suggestions.filter(
(suggestion) =>
suggestion.range.startLineNumber == position.lineNumber &&
suggestion.range.startColumn >= position.column - 3,
);

// Avoid providing suggestions if the character before the cursor is not a letter, number, or whitespace
if (
!/[a-zA-Z0-9\s]/.test(model.getValue().charAt(position.column - 2))
) {
return {
items: [],
};
}

return {
items: localSuggestions.map((suggestion) =>
new CompletionFormatter(model, position).format(
suggestion.insertText,
suggestion.range,
),
),
};
},
freeInlineCompletions: () => {},
},
);

return () => provider.dispose();
}, [monaco, completion, stop, cachedSuggestions, language]);

return (
<Editor
height="90vh"
defaultLanguage={language}
defaultValue="# Start typing..."
loading={<CircularSpinner />}
theme={useTheme().resolvedTheme === "dark" ? "vs-dark" : "vs"}
options={{
autoClosingBrackets: "never",
autoClosingQuotes: "never",
formatOnType: true,
formatOnPaste: true,
trimAutoWhitespace: true,
}}
onChange={handleEditorChange}
onMount={(editor, monaco) => {
editorRef.current = editor;
}}
/>
);
};

export default TextEditor;

4. Completion Formatting

Our last step will be creating a formatter for our completions to adjust them for the context. Start by creating the file src/components/editor/completion-formatter.ts:

import * as monacoeditor from "monaco-editor";

const OPENING_BRACKETS = ["(", "[", "{"];
const CLOSING_BRACKETS = [")", "]", "}"];
const QUOTES = ['"', "'", "`"];
export const ALL_BRACKETS = [...OPENING_BRACKETS, ...CLOSING_BRACKETS] as const;
export type Bracket = (typeof ALL_BRACKETS)[number];

class CompletionFormatter {
private _characterAfterCursor: string;
private _completion = "";
private _normalisedCompletion = "";
private _originalCompletion = "";
private _textAfterCursor: string;
private _lineText: string;
private _characterBeforeCursor: string;
private _editor: monacoeditor.editor.ITextModel;
private _cursorPosition: monacoeditor.Position;
private _lineCount: number;

constructor(
editor: monacoeditor.editor.ITextModel,
position: monacoeditor.Position,
) {
this._editor = editor;
this._cursorPosition = position;
const lineEndPosition = editor.getFullModelRange()?.getEndPosition();
const textAfterRange = new monacoeditor.Range(
/* Start position */ this._cursorPosition.lineNumber,
/* Start column */ this._cursorPosition.column,
/* End position */ lineEndPosition?.lineNumber ?? 1,
/* End column */ lineEndPosition?.column ?? 1,
);
this._lineText = editor.getLineContent(this._cursorPosition.lineNumber);
this._textAfterCursor = editor.getValueInRange(textAfterRange);
this._editor = editor;
this._characterBeforeCursor =
this._lineText[this._cursorPosition.column - 2] ?? "";
this._characterAfterCursor =
this._lineText[this._cursorPosition.column] ?? "";
this._lineCount = editor.getLineCount();
}

// Check if the open and close brackets are a matching pair
private isMatchingPair = (open?: Bracket, close?: string): boolean => {
return (
(open === "(" && close === ")") ||
(open === "[" && close === "]") ||
(open === "{" && close === "}")
);
};

// Match the completion brackets to ensure they are balanced
private matchCompletionBrackets = (): CompletionFormatter => {
let accumulatedCompletion = "";
const openBrackets: Bracket[] = [];
for (const character of this._originalCompletion) {
if (OPENING_BRACKETS.includes(character)) {
openBrackets.push(character);
}

if (CLOSING_BRACKETS.includes(character)) {
if (
openBrackets.length &&
this.isMatchingPair(openBrackets[openBrackets.length - 1], character)
) {
openBrackets.pop();
} else {
break;
}
}
accumulatedCompletion += character;
}

// If the brackets are not balanced, return the original completion, otherwise return the matched completion
this._completion =
accumulatedCompletion.trimEnd() || this._originalCompletion.trimEnd();

return this;
};

private ignoreBlankLines = (): CompletionFormatter => {
// If the completion is a blank line, return an empty string
if (
this._completion.trimStart() === "" &&
this._originalCompletion !== "\n"
) {
this._completion = this._completion.trim();
}
return this;
};

// Remove leading and trailing whitespace from the text
private normalise = (text: string) => text?.trim();

private removeDuplicateStartOfSuggestions(): this {
// Remove the text that is already present in the editor from the completion
const before = this._editor
.getValueInRange(
new monacoeditor.Range(
1,
1,
this._cursorPosition.lineNumber,
this._cursorPosition.column,
),
)
.trim();

const completion = this.normalise(this._completion);

const maxLength = Math.min(completion.length, before.length);
let overlapLength = 0;

for (let length = 1; length <= maxLength; length++) {
const endOfBefore = before.substring(before.length - length);
const startOfCompletion = completion.substring(0, length);
if (endOfBefore === startOfCompletion) {
overlapLength = length;
}
}

// Remove the overlapping part from the start of completion
if (overlapLength > 0) {
this._completion = this._completion.substring(overlapLength);
}

return this;
}

// Check if the cursor is in the middle of a word
private isCursorAtMiddleOfWord() {
return (
this._characterBeforeCursor &&
/\w/.test(this._characterBeforeCursor) &&
/\w/.test(this._characterAfterCursor)
);
}

// Remove unnecessary quotes in the middle of the completion
private removeUnnecessaryMiddleQuote(): CompletionFormatter {
const startsWithQuote = QUOTES.includes(this._completion[0] ?? "");
const endsWithQuote = QUOTES.includes(
this._completion[this._completion.length - 1] ?? "",
);

if (startsWithQuote && endsWithQuote) {
this._completion = this._completion.substring(1);
}

if (endsWithQuote && this.isCursorAtMiddleOfWord()) {
this._completion = this._completion.slice(0, -1);
}

return this;
}

// Remove duplicate lines that are already present in the editor
private preventDuplicateLines = (): CompletionFormatter => {
let nextLineIndex = this._cursorPosition.lineNumber + 1;
while (
nextLineIndex < this._cursorPosition.lineNumber + 3 &&
nextLineIndex < this._lineCount
) {
const line = this._editor.getLineContent(nextLineIndex);
if (this.normalise(line) === this.normalise(this._originalCompletion)) {
this._completion = "";
return this;
}
nextLineIndex++;
}
return this;
};

// Remove newlines after spaces or newlines
public removeInvalidLineBreaks = (): CompletionFormatter => {
if (this._completion.endsWith("\n")) {
this._completion = this._completion.trimEnd();
}
return this;
};

private newLineCount = () => {
return this._completion.match(/\n/g) || [];
};

private getLastLineColumnCount = () => {
const lines = this._completion.split("\n");
return lines[lines.length - 1].length;
};

private trimStart = () => {
const firstNonSpaceIndex = this._completion.search(/\S/);

/* If the first non-space character is in front of the cursor, remove it */
if (firstNonSpaceIndex > this._cursorPosition.column - 1) {
this._completion = this._completion.substring(firstNonSpaceIndex);
}

return this;
};

private stripMarkdownAndSuggestionText = () => {
// Remove the backticks and the language name from a code block
this._completion = this._completion.replace(/```.*\n/g, "");
this._completion = this._completion.replace(/```/g, "");
this._completion = this._completion.replace(/`/g, "");

// Remove variations of "# Suggestion:" and "# Suggestions:" if they exist
this._completion = this._completion.replace(/# ?Suggestions?: ?/g, "");

return this;
};

private getNoTextBeforeOrAfter = () => {
const textAfter = this._textAfterCursor;
const textBeforeRange = new monacoeditor.Range(
0,
0,
this._cursorPosition.lineNumber,
this._cursorPosition.column,
);

const textBefore = this._editor.getValueInRange(textBeforeRange);

return !textAfter || !textBefore;
};

private ignoreContextCompletionAtStartOrEnd = () => {
const isNoTextBeforeOrAfter = this.getNoTextBeforeOrAfter();

const contextMatch = this._normalisedCompletion.match(
/\/\*\s*Language:\s*(.*)\s*\*\//,
);

const extensionContext = this._normalisedCompletion.match(
/\/\*\s*File extension:\s*(.*)\s*\*\//,
);

const commentMatch = this._normalisedCompletion.match(/\/\*\s*\*\//);

if (
isNoTextBeforeOrAfter &&
(contextMatch || extensionContext || commentMatch)
) {
this._completion = "";
}

return this;
};

// Format the completion based on the cursor position, formatted completion, and range
private formatCompletion = (range: monacoeditor.IRange) => {
const newLineCount = this.newLineCount();
const getLastLineLength = this.getLastLineColumnCount();
return {
insertText: this._completion,
range: {
startLineNumber: this._cursorPosition.lineNumber,
startColumn: this._cursorPosition.column,
endLineNumber: this._cursorPosition.lineNumber + newLineCount.length,
endColumn:
this._cursorPosition.lineNumber === range.startLineNumber &&
newLineCount.length === 0
? this._cursorPosition.column + getLastLineLength
: getLastLineLength,
},
};
};

public format = (
insertText: string,
range: monacoeditor.IRange,
): { insertText: string; range: monacoeditor.IRange } => {
this._completion = "";
this._normalisedCompletion = this.normalise(insertText);
this._originalCompletion = insertText;
return this.matchCompletionBrackets()
.ignoreBlankLines()
.removeDuplicateStartOfSuggestions()
.removeUnnecessaryMiddleQuote()
.preventDuplicateLines()
.removeInvalidLineBreaks()
.trimStart()
.stripMarkdownAndSuggestionText()
.ignoreContextCompletionAtStartOrEnd()
.formatCompletion(range);
};
}

export { CompletionFormatter };
Our finished product!

We’re done!

With that, we’re all done, and you have a functioning browser based autocomplete to use in any of your projects

I hope you found this helpful — and if you did, I’d very much appreciate a follow, share or applause to help anyone else looking to create something similar.

Happy Hacking!

Need help working with AI, Agentic, or NLP-based systems? Let’s talk about it.

--

--