Skip to content

Commit

Permalink
Support alwaysOn: false, and an explicit command (#39)
Browse files Browse the repository at this point in the history
* Add always-on option

* Support explicit completion request

* Reorganize files, split requestCompletion into its own file

* Fix comment
  • Loading branch information
tmcw authored Jul 25, 2024
1 parent 450fb92 commit 11a967d
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 74 deletions.
3 changes: 3 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ <h3>TypeScript AI autocompletion</h3>

<h3>Python AI autocompletion</h3>
<div id="editor-python"></div>

<h3>TypeScript AI autocompletion (cmd+k to trigger completion)</h3>
<div id="editor-explicit"></div>
</main>
<script type="module" src="./index.ts"></script>
</body>
Expand Down
43 changes: 43 additions & 0 deletions demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { EditorView, basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
import {
codeiumOtherDocumentsConfig,
startCompletion,
Language,
copilotPlugin,
} from "../src/plugin.js";
import { python } from "@codemirror/lang-python";
import { keymap } from "@codemirror/view";

new EditorView({
doc: "// Factorial function",
Expand Down Expand Up @@ -41,6 +43,47 @@ const hiddenValue = "https://macwright.com/"`,
parent: document.querySelector("#editor")!,
});

new EditorView({
doc: "// Factorial function (explicit trigger)",
extensions: [
basicSetup,
javascript({
typescript: true,
jsx: true,
}),
codeiumOtherDocumentsConfig.of({
override: () => [
{
absolutePath: "https://esm.town/v/foo.ts",
text: `export const foo = 10;
const hiddenValue = "https://macwright.com/"`,
language: Language.TYPESCRIPT,
editorLanguage: "typescript",
},
],
}),
copilotPlugin({
apiKey: "d49954eb-cfba-4992-980f-d8fb37f0e942",
shouldComplete(context) {
if (context.tokenBefore(["String"])) {
return true;
}
const match = context.matchBefore(/(@(?:\w*))(?:[./](\w*))?/);
return !match;
},
alwaysOn: false,
}),
keymap.of([
{
key: "Cmd-k",
run: startCompletion,
},
]),
],
parent: document.querySelector("#editor-explicit")!,
});

new EditorView({
doc: "def hi_python():",
extensions: [
Expand Down
6 changes: 6 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
addSuggestions,
clearSuggestion,
} from "./effects.js";
import { requestCompletion } from "./requestCompletion.js";

/**
* Accepting a suggestion: we remove the ghost text, which
Expand Down Expand Up @@ -174,3 +175,8 @@ export function sameKeyCommand(view: EditorView, key: string) {
}
return rejectSuggestionCommand(view);
}

export const startCompletion: Command = (view: EditorView) => {
requestCompletion(view);
return true;
};
81 changes: 7 additions & 74 deletions src/completionRequester.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { CompletionContext, completionStatus } from "@codemirror/autocomplete";
import { ChangeSet, Transaction } from "@codemirror/state";
import { EditorView, type ViewUpdate } from "@codemirror/view";
import { completionsToChangeSpec, getCodeiumCompletions } from "./codeium.js";
import {
acceptSuggestion,
addSuggestions,
clearSuggestion,
} from "./effects.js";
import { acceptSuggestion, clearSuggestion } from "./effects.js";
import { completionDecoration } from "./completionDecoration.js";
import { copilotEvent, copilotIgnore } from "./annotations.js";
import { codeiumConfig, codeiumOtherDocumentsConfig } from "./config.js";
import { copilotEvent } from "./annotations.js";
import { codeiumConfig } from "./config.js";
import { requestCompletion } from "./requestCompletion.js";

/**
* To request a completion, the document needs to have been
Expand Down Expand Up @@ -57,7 +52,7 @@ export function completionRequester() {

return EditorView.updateListener.of((update: ViewUpdate) => {
const config = update.view.state.facet(codeiumConfig);
const { override } = update.view.state.facet(codeiumOtherDocumentsConfig);
if (!config.alwaysOn) return;

if (!shouldRequestCompletion(update)) return;

Expand Down Expand Up @@ -85,76 +80,14 @@ export function completionRequester() {
return;
}

const source = state.doc.toString();

// Set a new timeout to request completion
timeout = setTimeout(async () => {
// Check if the position has changed
if (pos !== lastPos) return;

const otherDocuments = await override();

// Request completion from the server
try {
const completionResult = await getCodeiumCompletions({
text: source,
cursorOffset: pos,
config,
otherDocuments,
});

if (
!completionResult ||
completionResult.completionItems.length === 0
) {
return;
}

// Check if the position is still the same. If
// it has changed, ignore the code that we just
// got from the API and don't show anything.
if (
!(
pos === lastPos &&
completionStatus(update.view.state) !== "active" &&
update.view.hasFocus
)
) {
return;
}

// Dispatch an effect to add the suggestion
// If the completion starts before the end of the line,
// check the end of the line with the end of the completion
const changeSpecs = completionsToChangeSpec(completionResult);

const index = 0;
const firstSpec = changeSpecs.at(index);
if (!firstSpec) return;
const insertChangeSet = ChangeSet.of(firstSpec, state.doc.length);
const reverseChangeSet = insertChangeSet.invert(state.doc);

update.view.dispatch({
changes: insertChangeSet,
effects: addSuggestions.of({
index,
reverseChangeSet,
changeSpecs,
}),
annotations: [
copilotIgnore.of(null),
copilotEvent.of(null),
Transaction.addToHistory.of(false),
],
});
} catch (error) {
console.warn("copilot completion failed", error);
// Javascript wait for 500ms for some reason is necessary here.
// TODO - FIGURE OUT WHY THIS RESOLVES THE BUG

await new Promise((resolve) => setTimeout(resolve, 300));
}
await requestCompletion(update.view, lastPos);
}, config.timeout);

// Update the last position
lastPos = pos;
});
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface CodeiumConfig {
* when there are multiple suggestions to cycle through.
*/
widgetClass?: typeof DefaultCycleWidget | null;

/**
* Always request completions after a delay
*/
alwaysOn?: boolean;
}

export const codeiumConfig = Facet.define<
Expand All @@ -50,6 +55,7 @@ export const codeiumConfig = Facet.define<
language: Language.TYPESCRIPT,
timeout: 150,
widgetClass: DefaultCycleWidget,
alwaysOn: true,
},
{},
);
Expand Down
2 changes: 2 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
rejectSuggestionCommand,
acceptSuggestionCommand,
nextSuggestionCommand,
startCompletion,
} from "./commands.js";
import {
type CodeiumConfig,
Expand Down Expand Up @@ -112,6 +113,7 @@ export {
codeiumConfig,
codeiumOtherDocumentsConfig,
nextSuggestionCommand,
startCompletion,
type CodeiumOtherDocumentsConfig,
type CodeiumConfig,
};
Expand Down
81 changes: 81 additions & 0 deletions src/requestCompletion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { completionStatus } from "@codemirror/autocomplete";
import { ChangeSet, Transaction } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { completionsToChangeSpec, getCodeiumCompletions } from "./codeium.js";
import { addSuggestions } from "./effects.js";
import { copilotEvent, copilotIgnore } from "./annotations.js";
import { codeiumConfig, codeiumOtherDocumentsConfig } from "./config.js";

/**
* Inner 'requestCompletion' API, which can optionally
* be run all the time if you set `alwaysOn`
*/
export async function requestCompletion(view: EditorView, lastPos?: number) {
const config = view.state.facet(codeiumConfig);
const { override } = view.state.facet(codeiumOtherDocumentsConfig);

const otherDocuments = await override();

// Get the current position and source
const state = view.state;
const pos = state.selection.main.head;
const source = state.doc.toString();

// Request completion from the server
try {
const completionResult = await getCodeiumCompletions({
text: source,
cursorOffset: pos,
config,
otherDocuments,
});

if (!completionResult || completionResult.completionItems.length === 0) {
return;
}

// Check if the position is still the same. If
// it has changed, ignore the code that we just
// got from the API and don't show anything.
if (
!(
(lastPos === undefined || pos === lastPos) &&
completionStatus(view.state) !== "active" &&
view.hasFocus
)
) {
return;
}

// Dispatch an effect to add the suggestion
// If the completion starts before the end of the line,
// check the end of the line with the end of the completion
const changeSpecs = completionsToChangeSpec(completionResult);

const index = 0;
const firstSpec = changeSpecs.at(index);
if (!firstSpec) return;
const insertChangeSet = ChangeSet.of(firstSpec, state.doc.length);
const reverseChangeSet = insertChangeSet.invert(state.doc);

view.dispatch({
changes: insertChangeSet,
effects: addSuggestions.of({
index,
reverseChangeSet,
changeSpecs,
}),
annotations: [
copilotIgnore.of(null),
copilotEvent.of(null),
Transaction.addToHistory.of(false),
],
});
} catch (error) {
console.warn("copilot completion failed", error);
// Javascript wait for 300ms for some reason is necessary here.
// TODO - FIGURE OUT WHY THIS RESOLVES THE BUG

await new Promise((resolve) => setTimeout(resolve, 300));
}
}

0 comments on commit 11a967d

Please sign in to comment.