Skip to content

Commit

Permalink
Fix demo copy, refactor big plugin file into sub-files
Browse files Browse the repository at this point in the history
  • Loading branch information
tmcw committed Mar 28, 2024
1 parent 115f708 commit 0145552
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 326 deletions.
2 changes: 1 addition & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ <h1>
you AI autocompletions powered by Codeium.
</p>

<h3>TypeScript environment in a web worker</h3>
<h3>TypeScript AI autocompletion</h3>
<div id="app">
<div id="editor"></div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Annotation } from "@codemirror/state";

export const copilotEvent = Annotation.define<null>();
2 changes: 1 addition & 1 deletion src/codeium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function getCodeiumCompletions({
metadata: {
ideName: "web",
ideVersion: "unknown",
extensionName: "@val-town/unofficial",
extensionName: "@valtown/codemirror-codeium",
extensionVersion: "unknown",
apiKey: EDITOR_API_KEY,
sessionId: sessionId,
Expand Down
75 changes: 75 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Transaction, EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { copilotEvent } from "./annotations.js";
import { completionDecoration } from "./completionDecoration.js";
import { acceptSuggestion, clearSuggestion } from "./effects.js";

export function acceptSuggestionCommand(view: EditorView) {
// We delete the ghost text and insert the suggestion.
// We also set the cursor to the end of the suggestion.
const stateField = view.state.field(completionDecoration)!;
const ghostTexts = stateField.ghostTexts;

if (!ghostTexts) {
return false;
}

const reverseReverseChangeSet = stateField.reverseChangeSet?.invert(
view.state.doc,
);

// This is removing the previous ghost text. Don't
// add this to history.
view.dispatch({
changes: stateField.reverseChangeSet,
effects: acceptSuggestion.of(null),
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
});

let lastIndex = 0;
reverseReverseChangeSet?.iterChangedRanges((_fromA, _toA, _fromB, toB) => {
lastIndex = toB;
});

view.dispatch({
changes: reverseReverseChangeSet,
selection: EditorSelection.cursor(lastIndex),
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(true)],
});

return true;
}

export function rejectSuggestionCommand(view: EditorView) {
// We delete the suggestion, then carry through with the original keypress
const stateField = view.state.field(completionDecoration)!;
const ghostTexts = stateField.ghostTexts;

if (!ghostTexts?.length) {
return false;
}

view.dispatch({
changes: stateField.reverseChangeSet,
effects: clearSuggestion.of(null),
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
});

return false;
}

// TODO: this isn't full reimplemented yet.
export function sameKeyCommand(view: EditorView, key: string) {
// When we type a key that is the same as the first letter of the suggestion, we delete the first letter of the suggestion and carry through with the original keypress
const ghostTexts = view.state.field(completionDecoration)!.ghostTexts;

if (!ghostTexts || !ghostTexts.length) {
return false;
}

if (key === "Tab") {
return acceptSuggestionCommand(view);
} else {
return rejectSuggestionCommand(view);
}
}
74 changes: 74 additions & 0 deletions src/completionDecoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { StateField, EditorState, Transaction } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
import {
addSuggestions,
acceptSuggestion,
clearSuggestion,
} from "./effects.js";
import { CompletionState } from "./types.js";

const ghostMark = Decoration.mark({ class: "cm-ghostText" });

export const completionDecoration = StateField.define<CompletionState>({
create(_state: EditorState) {
return { ghostTexts: null };
},
update(state: CompletionState, transaction: Transaction) {
for (const effect of transaction.effects) {
if (effect.is(addSuggestions)) {
// When adding a suggestion, we set th ghostText

// NOTE: here we're adjusting the decoration range
// to refer to locations in the document _after_ we've
// inserted the text.
let decorationOffset = 0;
const decorations = Decoration.set(
effect.value.suggestions.map((suggestion) => {
const endGhostText =
suggestion.cursorPos + suggestion.displayText.length;
let range = ghostMark.range(
decorationOffset + suggestion.cursorPos,
decorationOffset + endGhostText,
);
decorationOffset += suggestion.displayText.length;
return range;
}),
);

// TODO
return {
decorations,
reverseChangeSet: effect.value.reverseChangeSet,
ghostTexts: effect.value.suggestions.map((suggestion) => {
const endGhostText =
suggestion.cursorPos + suggestion.displayText.length;
return {
text: suggestion.text,
displayText: suggestion.text,
startPos: suggestion.startPos,
endPos: suggestion.endPos,
decorations,
// TODO: what's the difference between this
// and startPos?
displayPos: suggestion.cursorPos,
endReplacement: suggestion.endReplacement,
endGhostText,
};
}),
};
} else if (effect.is(acceptSuggestion)) {
if (state.ghostTexts) {
return { ghostTexts: null };
}
} else if (effect.is(clearSuggestion)) {
return { ghostTexts: null };
}
}

return state;
},
provide: (field) =>
EditorView.decorations.from(field, (value) => {
return value.decorations || Decoration.none;
}),
});
150 changes: 150 additions & 0 deletions src/completionRequester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { completionStatus } from "@codemirror/autocomplete";
import { ChangeSet, Transaction } from "@codemirror/state";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { getCodeiumCompletions } from "./codeium.js";
import {
acceptSuggestion,
addSuggestions,
clearSuggestion,
} from "./effects.js";
import { completionDecoration } from "./completionDecoration.js";
import { copilotEvent } from "./annotations.js";

// milliseconds before cancelling request
// against codeium
const TIMEOUT = 150;

/**
* To request a completion, the document needs to have been
* updated and the update should not have been because
* of accepting or clearing a suggestion.
*/
function shouldRequestCompletion(update: ViewUpdate) {
return (
update.docChanged &&
!update.transactions.some((tr) =>
tr.effects.some((e) => e.is(acceptSuggestion) || e.is(clearSuggestion)),
)
);
}

/**
* Don't request a completion if we've already
* done so, or it's a copilot event we're responding
* to, or if the view is not focused.
*/
function shouldIgnoreUpdate(update: ViewUpdate) {
// not focused
if (!update.view.hasFocus) return true;

// contains ghost text
if (update.state.field(completionDecoration).ghostTexts != null) return true;

// is autocompleting
if (completionStatus(update.state) === "active") return true;

// bad update
for (const tr of update.transactions) {
if (tr.annotation(copilotEvent) !== undefined) {
return true;
}
}
}

/**
* A view plugin that requests completions from the server after a delay
*/
export function completionRequester() {
let timeout: any = null;
let lastPos = 0;

return EditorView.updateListener.of((update: ViewUpdate) => {
if (!shouldRequestCompletion(update)) return;

// Cancel the previous timeout
if (timeout) {
clearTimeout(timeout);
}

if (shouldIgnoreUpdate(update)) {
return;
}

// Get the current position and source
const state = update.state;
const pos = state.selection.main.head;
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;

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

if (!completionResult || completionResult.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 insertChangeSet = ChangeSet.of(
completionResult.map((part) => ({
from: Number(part.offset),
to: Number(part.offset),
insert: part.text,
})),
state.doc.length,
);

const reverseChangeSet = insertChangeSet.invert(state.doc);

update.view.dispatch({
changes: insertChangeSet,
effects: addSuggestions.of({
reverseChangeSet,
suggestions: completionResult.map((part) => ({
displayText: part.text,
endReplacement: 0, // "",
text: part.text,
cursorPos: pos,
startPos: Number(part.offset),
endPos: Number(part.offset) + part.text.length,
})),
}),
annotations: [
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));
}
}, TIMEOUT);
// Update the last position
lastPos = pos;
});
}
10 changes: 10 additions & 0 deletions src/effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ChangeSet, StateEffect } from "@codemirror/state";
import { Suggestion } from "./types.js";

// Effects to tell StateEffect what to do with GhostText
export const addSuggestions = StateEffect.define<{
reverseChangeSet: ChangeSet;
suggestions: Suggestion[];
}>();
export const acceptSuggestion = StateEffect.define<null>();
export const clearSuggestion = StateEffect.define<null>();
Loading

0 comments on commit 0145552

Please sign in to comment.