Skip to content
This repository was archived by the owner on Sep 19, 2025. It is now read-only.

Commit 7c6dff0

Browse files
authored
Cycle completions (#32)
* Add ability to cycle through completions * Don’t store originalDocument * Add cycle widget * Switch back to ctrl
1 parent 3b3a97f commit 7c6dff0

File tree

8 files changed

+213
-24
lines changed

8 files changed

+213
-24
lines changed

demo/demo.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,32 @@ main {
5454
.cm-ghostText:hover {
5555
background: #eee;
5656
}
57+
58+
.cm-codeium-cycle {
59+
font-size: 9px;
60+
background-color: #eee;
61+
padding: 2px;
62+
border-radius: 2px;
63+
display: inline-block;
64+
}
65+
66+
.cm-codeium-cycle-key {
67+
font-size: 9px;
68+
font-family: monospace;
69+
display: inline-block;
70+
padding: 2px;
71+
border-radius: 2px;
72+
border: none;
73+
background-color: lightblue;
74+
margin-left: 5px;
75+
}
76+
77+
.cm-codeium-cycle-key:hover {
78+
background-color: deepskyblue;
79+
}
80+
81+
.cm-codeium-cycle-explanation {
82+
font-family: monospace;
83+
display: inline-block;
84+
padding: 2px;
85+
}

demo/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,7 @@ import {
88
import { python } from "@codemirror/lang-python";
99

1010
new EditorView({
11-
doc: `let hasAnError: string = 10;
12-
13-
function increment(num: number) {
14-
return num + 1;
15-
}
16-
17-
increment('not a number');`,
11+
doc: "// Factorial function",
1812
extensions: [
1913
basicSetup,
2014
javascript({

src/commands.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { Transaction, EditorSelection } from "@codemirror/state";
2-
import type { EditorView } from "@codemirror/view";
1+
import { Transaction, EditorSelection, ChangeSet } from "@codemirror/state";
2+
import type { Command, EditorView } from "@codemirror/view";
33
import { copilotEvent, copilotIgnore } from "./annotations.js";
44
import { completionDecoration } from "./completionDecoration.js";
5-
import { acceptSuggestion, clearSuggestion } from "./effects.js";
5+
import {
6+
acceptSuggestion,
7+
addSuggestions,
8+
clearSuggestion,
9+
} from "./effects.js";
610

711
/**
812
* Accepting a suggestion: we remove the ghost text, which
@@ -53,6 +57,79 @@ export function acceptSuggestionCommand(view: EditorView) {
5357
return true;
5458
}
5559

60+
/**
61+
* Cycle through suggested AI completed code.
62+
*/
63+
export const nextSuggestionCommand: Command = (view: EditorView) => {
64+
const { state } = view;
65+
// We delete the suggestion, then carry through with the original keypress
66+
const stateField = state.field(completionDecoration);
67+
68+
if (!stateField) {
69+
return false;
70+
}
71+
72+
const { changeSpecs } = stateField;
73+
74+
if (changeSpecs.length < 2) {
75+
return false;
76+
}
77+
78+
// Loop through next suggestion.
79+
const index = (stateField.index + 1) % changeSpecs.length;
80+
const nextSpec = changeSpecs.at(index);
81+
if (!nextSpec) {
82+
return false;
83+
}
84+
85+
/**
86+
* First, get the original document, by applying the stored
87+
* reverse changeset against the currently-displayed document.
88+
*/
89+
const originalDocument = stateField.reverseChangeSet.apply(state.doc);
90+
91+
/**
92+
* Get the changeset that we will apply that will
93+
*
94+
* 1. Reverse the currently-displayed suggestion, to get us back to
95+
* the original document
96+
* 2. Apply the next suggestion.
97+
*
98+
* It does both in the same changeset, so there is no flickering.
99+
*/
100+
const reverseCurrentSuggestionAndApplyNext = ChangeSet.of(
101+
stateField.reverseChangeSet,
102+
state.doc.length,
103+
).compose(ChangeSet.of(nextSpec, originalDocument.length));
104+
105+
/**
106+
* Generate the next changeset
107+
*/
108+
const nextSet = ChangeSet.of(nextSpec, originalDocument.length);
109+
const reverseChangeSet = nextSet.invert(originalDocument);
110+
111+
view.dispatch({
112+
changes: reverseCurrentSuggestionAndApplyNext,
113+
effects: addSuggestions.of({
114+
index,
115+
reverseChangeSet,
116+
changeSpecs,
117+
}),
118+
annotations: [
119+
// Tell upstream integrations to ignore this
120+
// change.
121+
copilotIgnore.of(null),
122+
// Tell ourselves not to request a completion
123+
// because of this change.
124+
copilotEvent.of(null),
125+
// Don't add this to history.
126+
Transaction.addToHistory.of(false),
127+
],
128+
});
129+
130+
return true;
131+
};
132+
56133
/**
57134
* Rejecting a suggestion: this looks at the currently-shown suggestion
58135
* and reverses it, clears the suggestion, and makes sure

src/completionDecoration.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
clearSuggestion,
1111
} from "./effects.js";
1212
import type { CompletionState } from "./types.js";
13+
import { codeiumConfig } from "./config.js";
1314

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

@@ -24,22 +25,36 @@ export const completionDecoration = StateField.define<CompletionState>({
2425
return null;
2526
},
2627
update(state: CompletionState, transaction: Transaction) {
28+
const config = transaction.state.facet(codeiumConfig);
2729
for (const effect of transaction.effects) {
2830
if (effect.is(addSuggestions)) {
2931
const { changeSpecs, index } = effect.value;
3032

3133
// NOTE: here we're adjusting the decoration range
3234
// to refer to locations in the document _after_ we've
3335
// inserted the text.
34-
const decorations = Decoration.set(
35-
changeSpecs[index]!.map((suggestionRange) => {
36-
const range = ghostMark.range(
37-
suggestionRange.absoluteStartPos,
38-
suggestionRange.absoluteEndPos,
39-
);
40-
return range;
41-
}),
42-
);
36+
const ranges = changeSpecs[index]!.map((suggestionRange) => {
37+
const range = ghostMark.range(
38+
suggestionRange.absoluteStartPos,
39+
suggestionRange.absoluteEndPos,
40+
);
41+
return range;
42+
});
43+
const widgetPos = ranges.at(-1)?.to;
44+
45+
const decorations = Decoration.set([
46+
...ranges,
47+
...(widgetPos !== undefined &&
48+
changeSpecs.length > 1 &&
49+
config.widgetClass
50+
? [
51+
Decoration.widget({
52+
widget: new config.widgetClass(index, changeSpecs.length),
53+
side: 1,
54+
}).range(widgetPos),
55+
]
56+
: []),
57+
]);
4358

4459
return {
4560
index,

src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Language } from "./api/proto/exa/codeium_common_pb/codeium_common_pb.js
33
import type { Document } from "./api/proto/exa/language_server_pb/language_server_pb.js";
44
import type { PartialMessage } from "@bufbuild/protobuf";
55
import type { CompletionContext } from "@codemirror/autocomplete";
6+
import { DefaultCycleWidget } from "./defaultCycleWidget.js";
67

78
export interface CodeiumConfig {
89
/**
@@ -30,6 +31,12 @@ export interface CodeiumConfig {
3031
* autocomplete sources.
3132
*/
3233
shouldComplete?: (context: CompletionContext) => boolean;
34+
35+
/**
36+
* The class for the widget that is shown at the end a suggestion
37+
* when there are multiple suggestions to cycle through.
38+
*/
39+
widgetClass?: typeof DefaultCycleWidget | null;
3340
}
3441

3542
export const codeiumConfig = Facet.define<
@@ -42,6 +49,7 @@ export const codeiumConfig = Facet.define<
4249
{
4350
language: Language.TYPESCRIPT,
4451
timeout: 150,
52+
widgetClass: DefaultCycleWidget,
4553
},
4654
{},
4755
);

src/defaultCycleWidget.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { WidgetType } from "@codemirror/view";
2+
3+
/**
4+
* Shown at the end of a suggestion if there are multiple
5+
* suggestions to cycle through.
6+
*/
7+
export class DefaultCycleWidget extends WidgetType {
8+
index: number;
9+
total: number;
10+
11+
constructor(index: number, total: number) {
12+
super();
13+
this.index = index;
14+
this.total = total;
15+
}
16+
17+
toDOM() {
18+
const wrap = document.createElement("span");
19+
wrap.setAttribute("aria-hidden", "true");
20+
wrap.className = "cm-codeium-cycle";
21+
const words = wrap.appendChild(document.createElement("span"));
22+
words.className = "cm-codeium-cycle-explanation";
23+
words.innerText = `${this.index + 1}/${this.total}`;
24+
const key = wrap.appendChild(document.createElement("button"));
25+
key.className = "cm-codeium-cycle-key";
26+
key.innerText = "→ (Ctrl ])";
27+
key.dataset.action = "codeium-cycle";
28+
return wrap;
29+
}
30+
31+
ignoreEvent() {
32+
return false;
33+
}
34+
}

src/plugin.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { EditorView, type ViewUpdate } from "@codemirror/view";
1+
import { EditorView, keymap, type ViewUpdate } from "@codemirror/view";
22
import { type Extension, Prec } from "@codemirror/state";
33
import { completionDecoration } from "./completionDecoration.js";
44
import { completionRequester } from "./completionRequester.js";
55
import {
66
sameKeyCommand,
77
rejectSuggestionCommand,
88
acceptSuggestionCommand,
9+
nextSuggestionCommand,
910
} from "./commands.js";
1011
import {
1112
type CodeiumConfig,
@@ -45,6 +46,13 @@ function isDecorationClicked(view: EditorView): boolean {
4546
function completionPlugin() {
4647
return EditorView.domEventHandlers({
4748
keydown(event, view) {
49+
// Ideally, we handle infighting between
50+
// the nextSuggestionCommand and this handler
51+
// by using precedence, but I can't get that to work
52+
// yet.
53+
if (event.key === "]" && event.ctrlKey) {
54+
return false;
55+
}
4856
if (
4957
event.key !== "Shift" &&
5058
event.key !== "Control" &&
@@ -55,7 +63,17 @@ function completionPlugin() {
5563
}
5664
return false;
5765
},
58-
mouseup(_event, view) {
66+
mouseup(event, view) {
67+
const target = event.target as HTMLElement;
68+
if (
69+
target.nodeName === "BUTTON" &&
70+
target.dataset.action === "codeium-cycle"
71+
) {
72+
nextSuggestionCommand(view);
73+
event.stopPropagation();
74+
event.preventDefault();
75+
return true;
76+
}
5977
if (isDecorationClicked(view)) {
6078
return acceptSuggestionCommand(view);
6179
}
@@ -64,6 +82,18 @@ function completionPlugin() {
6482
});
6583
}
6684

85+
/**
86+
* Next completion map
87+
*/
88+
function nextCompletionPlugin() {
89+
return keymap.of([
90+
{
91+
key: "Ctrl-]",
92+
run: nextSuggestionCommand,
93+
},
94+
]);
95+
}
96+
6797
/**
6898
* Changing the editor's focus - blurring it by clicking outside -
6999
* rejects the suggestion
@@ -81,6 +111,7 @@ export {
81111
copilotIgnore,
82112
codeiumConfig,
83113
codeiumOtherDocumentsConfig,
114+
nextSuggestionCommand,
84115
type CodeiumOtherDocumentsConfig,
85116
type CodeiumConfig,
86117
};
@@ -93,8 +124,9 @@ export function copilotPlugin(config: CodeiumConfig): Extension {
93124
return [
94125
codeiumConfig.of(config),
95126
completionDecoration,
96-
Prec.highest(completionPlugin()),
127+
Prec.highest(nextCompletionPlugin()),
97128
Prec.highest(viewCompletionPlugin()),
129+
Prec.high(completionPlugin()),
98130
completionRequester(),
99131
];
100132
}

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { ChangeSet } from "@codemirror/state";
2-
import type { DecorationSet } from "@codemirror/view";
1+
import type { Range, ChangeSet } from "@codemirror/state";
2+
import type { Decoration, DecorationSet } from "@codemirror/view";
33

44
/**
55
* We dispatch an effect that updates the CompletionState.

0 commit comments

Comments
 (0)