2024-02-15 13:56:31 -08:00
/// Thanks to the Cursor folks for their heavy lifting here.
2024-06-30 18:26:16 -07:00
/// This has been heavily modified from their original implementation but we are
/// still grateful.
2025-04-01 23:54:26 -07:00
import { completionStatus } from '@codemirror/autocomplete'
2024-02-15 13:56:31 -08:00
import { indentUnit } from '@codemirror/language'
2025-04-01 23:54:26 -07:00
import type { Extension } from '@codemirror/state'
2024-02-15 13:56:31 -08:00
import {
2025-04-01 15:31:19 -07:00
Annotation ,
EditorState ,
Prec ,
StateEffect ,
StateField ,
Transaction ,
} from '@codemirror/state'
2025-04-01 23:54:26 -07:00
import type {
DecorationSet ,
KeyBinding ,
PluginValue ,
ViewUpdate ,
} from '@codemirror/view'
import { Decoration , EditorView , ViewPlugin , keymap } from '@codemirror/view'
import type {
2025-04-01 15:31:19 -07:00
LanguageServerClient ,
2025-04-01 23:54:26 -07:00
LanguageServerOptions ,
} from '@kittycad/codemirror-lsp-client'
import {
2024-06-29 18:10:07 -07:00
docPathFacet ,
2024-02-19 12:33:16 -08:00
languageId ,
2025-04-01 23:54:26 -07:00
offsetToPos ,
posToOffset ,
2024-06-30 14:30:44 -07:00
} from '@kittycad/codemirror-lsp-client'
2025-04-01 23:54:26 -07:00
import { editorManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { deferExecution } from '@src/lib/utils'
import type { CopilotAcceptCompletionParams } from '@rust/kcl-lib/bindings/CopilotAcceptCompletionParams'
import type { CopilotCompletionResponse } from '@rust/kcl-lib/bindings/CopilotCompletionResponse'
import type { CopilotLspCompletionParams } from '@rust/kcl-lib/bindings/CopilotLspCompletionParams'
import type { CopilotRejectCompletionParams } from '@rust/kcl-lib/bindings/CopilotRejectCompletionParams'
2024-02-15 13:56:31 -08:00
2024-07-08 21:07:15 -07:00
const copilotPluginAnnotation = Annotation . define < boolean > ( )
export const copilotPluginEvent = copilotPluginAnnotation . of ( true )
2024-06-29 18:10:07 -07:00
2024-07-08 21:07:15 -07:00
const rejectSuggestionAnnotation = Annotation . define < boolean > ( )
export const rejectSuggestionCommand = rejectSuggestionAnnotation . of ( true )
2024-06-30 18:26:16 -07:00
2024-06-29 18:10:07 -07:00
// Effects to tell StateEffect what to do with GhostText
const addSuggestion = StateEffect . define < Suggestion > ( )
const acceptSuggestion = StateEffect . define < null > ( )
const clearSuggestion = StateEffect . define < null > ( )
const typeFirst = StateEffect . define < number > ( )
2024-02-15 13:56:31 -08:00
const ghostMark = Decoration . mark ( { class : 'cm-ghostText' } )
2024-06-29 18:10:07 -07:00
const changesDelay = 600
2024-02-15 13:56:31 -08:00
interface Suggestion {
text : string
displayText : string
cursorPos : number
startPos : number
endPos : number
endReplacement : number
uuid : string
}
interface CompletionState {
ghostText : GhostText | null
}
2024-06-29 18:10:07 -07:00
2024-02-15 13:56:31 -08:00
interface GhostText {
text : string
displayText : string
displayPos : number
startPos : number
endGhostText : number
endReplacement : number
endPos : number
decorations : DecorationSet
weirdInsert : boolean
uuid : string
}
2024-06-30 18:26:16 -07:00
const completionDecoration = StateField . define < CompletionState > ( {
2024-02-15 13:56:31 -08:00
create ( _state : EditorState ) {
return { ghostText : null }
} ,
update ( state : CompletionState , transaction : Transaction ) {
2024-06-29 18:10:07 -07:00
// We only care about events from this plugin.
2024-06-30 18:26:16 -07:00
if (
transaction . annotation ( copilotPluginEvent . type ) === undefined &&
transaction . annotation ( rejectSuggestionCommand . type ) === undefined
) {
2024-06-29 18:10:07 -07:00
return state
}
2024-02-15 13:56:31 -08:00
for ( const effect of transaction . effects ) {
if ( effect . is ( addSuggestion ) ) {
// When adding a suggestion, we set th ghostText
const {
text ,
displayText ,
endReplacement ,
cursorPos ,
startPos ,
endPos ,
uuid ,
} = effect . value
const endGhostText = cursorPos + displayText . length
const decorations = Decoration . set ( [
ghostMark . range ( cursorPos , endGhostText ) ,
] )
return {
ghostText : {
text ,
displayText ,
startPos ,
endPos ,
decorations ,
displayPos : cursorPos ,
endReplacement ,
endGhostText ,
weirdInsert : false ,
uuid ,
} ,
}
} else if ( effect . is ( acceptSuggestion ) ) {
if ( state . ghostText ) {
return { ghostText : null }
}
} else if ( effect . is ( typeFirst ) ) {
const numChars = effect . value
if ( state . ghostText && ! state . ghostText . weirdInsert ) {
let {
text ,
displayText ,
displayPos ,
startPos ,
endPos ,
endGhostText ,
decorations ,
endReplacement ,
uuid ,
} = state . ghostText
displayPos += numChars
displayText = displayText . slice ( numChars )
if ( startPos === endGhostText ) {
return { ghostText : null }
} else {
decorations = Decoration . set ( [
ghostMark . range ( displayPos , endGhostText ) ,
] )
return {
ghostText : {
text ,
displayText ,
startPos ,
endPos ,
decorations ,
endGhostText ,
endReplacement ,
uuid ,
displayPos ,
weirdInsert : false ,
} ,
}
}
}
} else if ( effect . is ( clearSuggestion ) ) {
return { ghostText : null }
}
}
// if (transaction.docChanged && state.ghostText) {
// if (transaction.
// onsole.log({changes: transaction.changes, transaction})
// const newGhostText = state.ghostText.decorations.map(transaction.changes)
// return {ghostText: {...state.ghostText, decorations: newGhostText}};
// }
return state
} ,
provide : ( field ) = >
EditorView . decorations . from ( field , ( value ) = >
value . ghostText ? value.ghostText.decorations : Decoration.none
) ,
} )
2024-06-29 18:10:07 -07:00
// A view plugin that requests completions from the server after a delay
export class CompletionRequester implements PluginValue {
private client : LanguageServerClient
private lastPos : number = 0
2024-06-30 14:30:44 -07:00
private queuedUids : string [ ] = [ ]
2024-06-29 18:10:07 -07:00
private _deffererUserSelect = deferExecution ( ( ) = > {
this . rejectSuggestionCommand ( )
} , changesDelay )
2024-02-15 13:56:31 -08:00
2024-07-11 16:05:19 -07:00
// When a doc update needs to be sent to the server, this holds the
// timeout handle for it. When null, the server has the up-to-date
// document.
private sendScheduledInput : number | null = null
2025-04-01 23:54:26 -07:00
constructor (
readonly view : EditorView ,
client : LanguageServerClient
) {
2024-06-29 18:10:07 -07:00
this . client = client
}
2024-02-15 13:56:31 -08:00
2024-06-29 18:10:07 -07:00
update ( viewUpdate : ViewUpdate ) {
2024-07-03 20:59:54 -07:00
// Make sure we are in a state where we can request completions.
if ( ! editorManager . copilotEnabled ) {
2024-06-29 18:10:07 -07:00
return
}
2024-02-15 13:56:31 -08:00
2024-07-03 20:59:54 -07:00
let isUserSelect = false
let isRelevant = false
for ( const tr of viewUpdate . transactions ) {
if ( tr . isUserEvent ( 'select' ) ) {
isUserSelect = true
break
} else if ( tr . isUserEvent ( 'input' ) ) {
isRelevant = true
} else if ( tr . isUserEvent ( 'delete' ) ) {
isRelevant = true
} else if ( tr . isUserEvent ( 'undo' ) ) {
isRelevant = true
} else if ( tr . isUserEvent ( 'redo' ) ) {
isRelevant = true
} else if ( tr . isUserEvent ( 'move' ) ) {
isRelevant = true
2024-07-08 21:07:15 -07:00
} else if ( tr . annotation ( copilotPluginEvent . type ) ) {
2024-07-03 20:59:54 -07:00
isRelevant = true
}
}
2024-06-29 18:10:07 -07:00
// If we have a user select event, we want to clear the ghost text.
2024-07-03 20:59:54 -07:00
if ( isUserSelect ) {
2024-06-29 18:10:07 -07:00
this . _deffererUserSelect ( true )
return
}
2024-07-03 20:59:54 -07:00
if ( ! isRelevant ) {
2024-07-01 21:05:31 -07:00
return
}
2024-07-03 19:28:46 -07:00
this . lastPos = this . view . state . selection . main . head
2024-07-11 16:05:19 -07:00
if ( viewUpdate . docChanged ) this . scheduleUpdateDoc ( )
}
scheduleUpdateDoc() {
if ( this . sendScheduledInput != null )
window . clearTimeout ( this . sendScheduledInput )
this . sendScheduledInput = window . setTimeout (
( ) = > this . updateDoc ( ) ,
changesDelay
)
}
updateDoc() {
if ( this . sendScheduledInput != null ) {
window . clearTimeout ( this . sendScheduledInput )
this . sendScheduledInput = null
}
if ( ! this . client . ready ) return
try {
2024-09-09 18:17:45 -04:00
this . requestCompletions ( ) . catch ( reportRejection )
2024-07-11 16:05:19 -07:00
} catch ( e ) {
console . error ( e )
}
}
ensureDocUpdated() {
if ( this . sendScheduledInput != null ) this . updateDoc ( )
2024-02-15 13:56:31 -08:00
}
2024-06-29 18:10:07 -07:00
ghostText ( ) : GhostText | null {
2024-07-03 19:28:46 -07:00
return this . view . state . field ( completionDecoration ) ? . ghostText || null
2024-06-29 18:10:07 -07:00
}
2024-02-15 13:56:31 -08:00
2024-06-29 18:10:07 -07:00
containsGhostText ( ) : boolean {
return this . ghostText ( ) !== null
}
2024-02-15 13:56:31 -08:00
2024-06-29 18:10:07 -07:00
autocompleting ( ) : boolean {
2024-07-03 19:28:46 -07:00
return completionStatus ( this . view . state ) === 'active'
2024-06-29 18:10:07 -07:00
}
notFocused ( ) : boolean {
2024-07-03 19:28:46 -07:00
return ! this . view . hasFocus
2024-02-15 13:56:31 -08:00
}
2024-06-29 18:10:07 -07:00
async requestCompletions ( ) : Promise < void > {
if (
this . containsGhostText ( ) ||
this . autocompleting ( ) ||
2024-07-03 19:28:46 -07:00
this . notFocused ( )
2024-06-29 18:10:07 -07:00
) {
return
}
2024-07-03 19:28:46 -07:00
const pos = this . view . state . selection . main . head
2024-06-29 18:10:07 -07:00
// Check if the position has changed
if ( pos !== this . lastPos ) {
return
}
// Get the current position and source
2024-07-03 19:28:46 -07:00
const state = this . view . state
2024-06-29 18:10:07 -07:00
const dUri = state . facet ( docPathFacet )
// Request completion from the server
2024-06-30 14:30:44 -07:00
const completionResult = await this . getCompletion ( {
2024-06-29 18:10:07 -07:00
doc : {
source : state.doc.toString ( ) ,
tabSize : state.facet ( EditorState . tabSize ) ,
indentSize : 1 ,
insertSpaces : true ,
2025-05-06 11:48:45 -05:00
// eslint-disable-next-line
2024-06-29 18:10:07 -07:00
path : dUri.split ( '/' ) . pop ( ) ! ,
uri : dUri ,
relativePath : dUri.replace ( 'file://' , '' ) ,
languageId : state.facet ( languageId ) ,
position : offsetToPos ( state . doc , pos ) ,
} ,
2024-02-15 13:56:31 -08:00
} )
2024-06-29 18:10:07 -07:00
if ( completionResult . completions . length === 0 ) {
return
}
let {
text ,
displayText ,
range : { start } ,
position ,
uuid ,
} = completionResult . completions [ 0 ]
if ( text . length === 0 || displayText . length === 0 ) {
return
}
const startPos = posToOffset ( state . doc , {
line : start.line ,
character : start.character ,
} )
if ( startPos === undefined ) {
return
}
const endGhostOffset = posToOffset ( state . doc , {
line : position.line ,
character : position.character ,
} )
if ( endGhostOffset === undefined ) {
return
}
const endGhostPos = endGhostOffset + displayText . length
// EndPos is the position that marks the complete end
// of what is to be replaced when we accept a completion
// result
const endPos = startPos + text . length
// Check if they changed position.
if ( pos !== this . lastPos ) {
return
}
// Make sure we are not currently completing.
if ( this . autocompleting ( ) || this . notFocused ( ) ) {
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.
2024-07-03 19:28:46 -07:00
const line = this . view . state . doc . lineAt ( pos )
2024-06-29 18:10:07 -07:00
if ( line . to !== pos ) {
2024-07-03 19:28:46 -07:00
const ending = this . view . state . doc . sliceString ( pos , line . to )
2024-06-29 18:10:07 -07:00
if ( displayText . endsWith ( ending ) ) {
displayText = displayText . slice ( 0 , displayText . length - ending . length )
} else if ( displayText . includes ( ending ) ) {
// Remove the ending
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
changes : {
from : pos ,
to : line.to ,
insert : '' ,
} ,
selection : { anchor : pos } ,
effects : typeFirst.of ( ending . length ) ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( false ) ] ,
} )
}
}
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
changes : {
from : pos ,
to : pos ,
insert : displayText ,
} ,
effects : [
addSuggestion . of ( {
displayText ,
endReplacement : endGhostPos ,
text ,
cursorPos : pos ,
startPos ,
endPos ,
uuid ,
} ) ,
] ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( false ) ] ,
} )
this . lastPos = pos
return
}
acceptSuggestionCommand ( ) : boolean {
const ghostText = this . ghostText ( )
if ( ! ghostText ) {
return false
}
// We delete the ghost text and insert the suggestion.
// We also set the cursor to the end of the suggestion.
const ghostTextStart = ghostText . displayPos
const ghostTextEnd = ghostText . endGhostText
const actualTextStart = ghostText . startPos
const actualTextEnd = ghostText . endPos
const replacementEnd = ghostText . endReplacement
const suggestion = ghostText . text
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
changes : {
from : ghostTextStart ,
to : ghostTextEnd ,
insert : '' ,
} ,
effects : acceptSuggestion.of ( null ) ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( false ) ] ,
} )
const tmpTextEnd = replacementEnd - ( ghostTextEnd - ghostTextStart )
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
changes : {
from : actualTextStart ,
to : tmpTextEnd ,
insert : suggestion ,
} ,
selection : { anchor : actualTextEnd } ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( true ) ] ,
2024-02-15 13:56:31 -08:00
} )
2024-09-09 18:17:45 -04:00
this . accept ( ghostText . uuid ) . catch ( reportRejection )
2024-02-15 13:56:31 -08:00
return true
}
2024-06-29 18:10:07 -07:00
rejectSuggestionCommand ( ) : boolean {
const ghostText = this . ghostText ( )
if ( ! ghostText ) {
return false
}
// We delete the suggestion, then carry through with the original keypress
const ghostTextStart = ghostText . displayPos
const ghostTextEnd = ghostText . endGhostText
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
changes : {
from : ghostTextStart ,
to : ghostTextEnd ,
insert : '' ,
} ,
effects : clearSuggestion.of ( null ) ,
2024-06-30 18:26:16 -07:00
annotations : [
rejectSuggestionCommand ,
2024-07-08 21:07:15 -07:00
copilotPluginEvent ,
2024-06-30 18:26:16 -07:00
Transaction . addToHistory . of ( false ) ,
] ,
2024-06-29 18:10:07 -07:00
} )
2024-09-09 18:17:45 -04:00
this . reject ( ) . catch ( reportRejection )
2024-06-29 18:10:07 -07:00
return false
}
sameKeyCommand ( key : string ) {
const ghostText = this . ghostText ( )
if ( ! ghostText ) {
return false
}
const tabKey = 'Tab'
// 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 ghostTextStart = ghostText . displayPos
2024-07-03 19:28:46 -07:00
const indent = this . view . state . facet ( indentUnit )
2024-06-29 18:10:07 -07:00
if ( key === tabKey && ghostText . displayText . startsWith ( indent ) ) {
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
selection : { anchor : ghostTextStart + indent . length } ,
effects : typeFirst.of ( indent . length ) ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( false ) ] ,
} )
return true
} else if ( key === tabKey ) {
return this . acceptSuggestionCommand ( )
} else if ( ghostText . weirdInsert || key !== ghostText . displayText [ 0 ] ) {
return this . rejectSuggestionCommand ( )
} else if ( ghostText . displayText . length === 1 ) {
return this . acceptSuggestionCommand ( )
} else {
// Use this to delete the first letter of the suggestion
2024-07-03 19:28:46 -07:00
this . view . dispatch ( {
2024-06-29 18:10:07 -07:00
selection : { anchor : ghostTextStart + 1 } ,
effects : typeFirst.of ( 1 ) ,
annotations : [ copilotPluginEvent , Transaction . addToHistory . of ( false ) ] ,
} )
return true
}
}
2024-06-30 14:30:44 -07:00
async getCompletion (
params : CopilotLspCompletionParams
) : Promise < CopilotCompletionResponse > {
const response : CopilotCompletionResponse = await this . client . requestCustom (
'copilot/getCompletions' ,
params
)
//
this . queuedUids = [ . . . response . completions . map ( ( c ) = > c . uuid ) ]
return response
}
async accept ( uuid : string ) {
const badUids = this . queuedUids . filter ( ( u ) = > u !== uuid )
this . queuedUids = [ ]
this . acceptCompletion ( { uuid } )
this . rejectCompletions ( { uuids : badUids } )
}
async reject() {
const badUids = this . queuedUids
this . queuedUids = [ ]
this . rejectCompletions ( { uuids : badUids } )
}
acceptCompletion ( params : CopilotAcceptCompletionParams ) {
this . client . notifyCustom ( 'copilot/notifyAccepted' , params )
}
rejectCompletions ( params : CopilotRejectCompletionParams ) {
this . client . notifyCustom ( 'copilot/notifyRejected' , params )
}
2024-02-15 13:56:31 -08:00
}
2024-06-29 18:10:07 -07:00
export const copilotPlugin = ( options : LanguageServerOptions ) : Extension = > {
2024-06-30 18:26:16 -07:00
let plugin : CompletionRequester | null = null
const completionPlugin = ViewPlugin . define (
2024-07-03 19:28:46 -07:00
( view ) = > ( plugin = new CompletionRequester ( view , options . client ) )
2024-06-30 18:26:16 -07:00
)
2024-06-29 18:10:07 -07:00
const domHandlers = EditorView . domEventHandlers ( {
2024-02-15 13:56:31 -08:00
keydown ( event , view ) {
if (
event . key !== 'Shift' &&
event . key !== 'Control' &&
event . key !== 'Alt' &&
2024-06-30 18:26:16 -07:00
event . key !== 'Backspace' &&
event . key !== 'Delete' &&
2024-02-15 13:56:31 -08:00
event . key !== 'Meta'
) {
2024-06-29 18:10:07 -07:00
if ( view . plugin === null ) return false
// Get the current plugin from the map.
const p = view . plugin ( completionPlugin )
if ( p === null ) return false
return p . sameKeyCommand ( event . key )
2024-02-15 13:56:31 -08:00
} else {
return false
}
} ,
} )
2024-07-02 10:08:02 -07:00
const rejectSuggestionCommand = ( view : EditorView ) : boolean = > {
// Get the current plugin from the map.
const p = view . plugin ( completionPlugin )
if ( p === null ) return false
return p . rejectSuggestionCommand ( )
}
2024-06-30 18:26:16 -07:00
const copilotAutocompleteKeymap : readonly KeyBinding [ ] = [
{
key : 'Tab' ,
run : ( view : EditorView ) : boolean = > {
if ( view . plugin === null ) return false
// Get the current plugin from the map.
const p = view . plugin ( completionPlugin )
if ( p === null ) return false
return p . sameKeyCommand ( 'Tab' )
} ,
} ,
{
key : 'Backspace' ,
2024-07-02 10:08:02 -07:00
run : rejectSuggestionCommand ,
2024-06-30 18:26:16 -07:00
} ,
{
key : 'Delete' ,
2024-07-02 10:08:02 -07:00
run : rejectSuggestionCommand ,
} ,
{ key : 'Mod-z' , run : rejectSuggestionCommand , preventDefault : true } ,
{
key : 'Mod-y' ,
mac : 'Mod-Shift-z' ,
run : rejectSuggestionCommand ,
preventDefault : true ,
} ,
{
linux : 'Ctrl-Shift-z' ,
run : rejectSuggestionCommand ,
preventDefault : true ,
} ,
{ key : 'Mod-u' , run : rejectSuggestionCommand , preventDefault : true } ,
{
key : 'Alt-u' ,
mac : 'Mod-Shift-u' ,
run : rejectSuggestionCommand ,
preventDefault : true ,
2024-06-30 18:26:16 -07:00
} ,
]
const copilotAutocompleteKeymapExt = Prec . highest (
keymap . computeN ( [ ] , ( ) = > [ copilotAutocompleteKeymap ] )
)
2024-02-19 12:33:16 -08:00
return [
2024-06-29 18:10:07 -07:00
completionPlugin ,
2024-06-30 18:26:16 -07:00
copilotAutocompleteKeymapExt ,
2024-06-29 18:10:07 -07:00
domHandlers ,
2024-02-19 12:33:16 -08:00
completionDecoration ,
2024-06-30 18:26:16 -07:00
EditorView . focusChangeEffect . of ( ( _ , focusing ) = > {
if ( plugin === null ) return null
plugin . rejectSuggestionCommand ( )
return null
2024-07-03 22:06:52 -07:00
} ) ,
2024-02-19 12:33:16 -08:00
]
2024-02-15 13:56:31 -08:00
}