From f5ed34fbf67c1406d687d2cad70b4cec05e7750c Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Thu, 2 Jul 2026 16:44:00 -0400 Subject: [PATCH 1/5] Codex: Fix code editor jump after tab-completion and = Fixes #12133 --- src/editor/plugins/lsp/copilot/index.test.ts | 198 +++++++++++++++++++ src/editor/plugins/lsp/copilot/index.ts | 23 ++- 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/editor/plugins/lsp/copilot/index.test.ts diff --git a/src/editor/plugins/lsp/copilot/index.test.ts b/src/editor/plugins/lsp/copilot/index.test.ts new file mode 100644 index 00000000000..5f2dc303eb7 --- /dev/null +++ b/src/editor/plugins/lsp/copilot/index.test.ts @@ -0,0 +1,198 @@ +import { + type CompletionContext, + autocompletion, + completionStatus, + startCompletion, +} from '@codemirror/autocomplete' +import { EditorState, Transaction } from '@codemirror/state' +import { EditorView } from 'codemirror' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + type LanguageServerClient, + docPathFacet, + languageId, +} from '@kittycad/codemirror-lsp-client' +import type { KclManager } from '@src/lang/KclManager' + +import { copilotPlugin } from './index' + +describe('copilotPlugin', () => { + let view: EditorView | null = null + + afterEach(() => { + view?.destroy() + view = null + document.body.innerHTML = '' + vi.useRealTimers() + }) + + it('rejects ghost text before ordinary keys when CodeMirror autocomplete is active', async () => { + vi.useFakeTimers() + + const client = { + ready: true, + requestCustom: vi.fn(async () => ({ + completions: [ + { + text: '=)', + displayText: '=)', + range: { start: { line: 0, character: 6 } }, + position: { line: 0, character: 6 }, + uuid: 'ghost-equals', + }, + ], + })), + notifyCustom: vi.fn(), + } as unknown as LanguageServerClient + + const kclManager = { copilotEnabled: true } as KclManager + const parent = document.body.appendChild(document.createElement('div')) + const explicitCompletionSource = (context: CompletionContext) => { + if (!context.explicit) return null + const word = context.matchBefore(/\w*/) + return { + from: word?.from ?? context.pos, + options: [{ label: 'length' }], + } + } + + view = new EditorView({ + state: EditorState.create({ + doc: 'lengt', + selection: { anchor: 5 }, + extensions: [ + docPathFacet.of('/test.kcl'), + languageId.of('kcl'), + autocompletion({ + override: [explicitCompletionSource], + }), + copilotPlugin( + { + client, + documentUri: 'file:///test.kcl', + workspaceFolders: [], + allowHTMLContent: true, + }, + kclManager + ), + ], + }), + parent, + }) + view.focus() + + view.dispatch({ + changes: { from: 5, insert: 'h' }, + selection: { anchor: 6 }, + annotations: Transaction.userEvent.of('input.type'), + }) + await vi.advanceTimersByTimeAsync(600) + + expect(view.state.doc.toString()).toBe('length=)') + expect(view.state.selection.main.head).toBe(6) + + expect(startCompletion(view)).toBe(true) + expect(completionStatus(view.state)).not.toBeNull() + + const event = new KeyboardEvent('keydown', { + key: '=', + bubbles: true, + cancelable: true, + }) + view.contentDOM.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + expect(view.state.doc.toString()).toBe('length') + expect(view.state.selection.main.head).toBe(6) + + view.dispatch({ + changes: { from: 6, insert: '=' }, + selection: { anchor: 7 }, + annotations: Transaction.userEvent.of('input.type'), + }) + + expect(view.state.doc.toString()).toBe('length=') + expect(view.state.selection.main.head).toBe(7) + }) + + it('rejects punctuation-leading ghost text instead of typing through it', async () => { + vi.useFakeTimers() + + const client = { + ready: true, + requestCustom: vi.fn(async () => ({ + completions: [ + { + text: ' =)', + displayText: ' =)', + range: { start: { line: 0, character: 3 } }, + position: { line: 0, character: 3 }, + uuid: 'ghost-assignment', + }, + ], + })), + notifyCustom: vi.fn(), + } as unknown as LanguageServerClient + + const kclManager = { copilotEnabled: true } as KclManager + const parent = document.body.appendChild(document.createElement('div')) + + view = new EditorView({ + state: EditorState.create({ + doc: 'fo', + selection: { anchor: 2 }, + extensions: [ + docPathFacet.of('/test.kcl'), + languageId.of('kcl'), + copilotPlugin( + { + client, + documentUri: 'file:///test.kcl', + workspaceFolders: [], + allowHTMLContent: true, + }, + kclManager + ), + ], + }), + parent, + }) + view.focus() + + view.dispatch({ + changes: { from: 2, insert: 'o' }, + selection: { anchor: 3 }, + annotations: Transaction.userEvent.of('input.type'), + }) + await vi.advanceTimersByTimeAsync(600) + + expect(view.state.doc.toString()).toBe('foo =)') + expect(view.state.selection.main.head).toBe(3) + + const spaceEvent = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }) + view.contentDOM.dispatchEvent(spaceEvent) + + expect(spaceEvent.defaultPrevented).toBe(false) + expect(view.state.doc.toString()).toBe('foo') + expect(view.state.selection.main.head).toBe(3) + + view.dispatch({ + changes: { from: 3, insert: ' ' }, + selection: { anchor: 4 }, + annotations: Transaction.userEvent.of('input.type'), + }) + view.dispatch({ + changes: { from: 4, insert: '=' }, + selection: { anchor: 5 }, + annotations: Transaction.userEvent.of('input.type'), + }) + + expect(view.state.doc.toString()).toBe('foo =') + expect(view.state.selection.main.head).toBe(5) + }) +}) diff --git a/src/editor/plugins/lsp/copilot/index.ts b/src/editor/plugins/lsp/copilot/index.ts index ac83714a7e5..7aee63b5e02 100644 --- a/src/editor/plugins/lsp/copilot/index.ts +++ b/src/editor/plugins/lsp/copilot/index.ts @@ -81,6 +81,21 @@ interface GhostText { uuid: string } +const typeThroughCharacter = /^[A-Za-z0-9_]$/ + +function canTypeThroughGhostText(key: string, displayText: string): boolean { + if (key.length !== 1) { + return true + } + + const nextGhostChar = displayText[0] + return ( + typeThroughCharacter.test(key) && + nextGhostChar !== undefined && + typeThroughCharacter.test(nextGhostChar) + ) +} + const completionDecoration = StateField.define({ create(_state: EditorState) { return { ghostText: null } @@ -293,7 +308,7 @@ export class CompletionRequester implements PluginValue { } autocompleting(): boolean { - return completionStatus(this.view.state) === 'active' + return completionStatus(this.view.state) !== null } notFocused(): boolean { @@ -511,6 +526,10 @@ export class CompletionRequester implements PluginValue { const tabKey = 'Tab' + if (this.autocompleting()) { + return this.rejectSuggestionCommand() + } + // 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 const indent = this.view.state.facet(indentUnit) @@ -524,6 +543,8 @@ export class CompletionRequester implements PluginValue { return true } else if (key === tabKey) { return this.acceptSuggestionCommand() + } else if (!canTypeThroughGhostText(key, ghostText.displayText)) { + return this.rejectSuggestionCommand() } else if (ghostText.weirdInsert || key !== ghostText.displayText[0]) { return this.rejectSuggestionCommand() } else if (ghostText.displayText.length === 1) { From 77c681d84fa37c573901933b7635277ccabba941 Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Thu, 2 Jul 2026 17:06:46 -0400 Subject: [PATCH 2/5] Cleanup --- src/editor/plugins/lsp/copilot/index.test.ts | 260 ++++++++++--------- src/editor/plugins/lsp/copilot/index.ts | 6 +- 2 files changed, 147 insertions(+), 119 deletions(-) diff --git a/src/editor/plugins/lsp/copilot/index.test.ts b/src/editor/plugins/lsp/copilot/index.test.ts index 5f2dc303eb7..d5e79caa875 100644 --- a/src/editor/plugins/lsp/copilot/index.test.ts +++ b/src/editor/plugins/lsp/copilot/index.test.ts @@ -4,7 +4,7 @@ import { completionStatus, startCompletion, } from '@codemirror/autocomplete' -import { EditorState, Transaction } from '@codemirror/state' +import { EditorState, type Extension, Transaction } from '@codemirror/state' import { EditorView } from 'codemirror' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -17,6 +17,89 @@ import type { KclManager } from '@src/lang/KclManager' import { copilotPlugin } from './index' +function createClient({ + text, + displayText = text, + character, + uuid, +}: { + text: string + displayText?: string + character: number + uuid: string +}): LanguageServerClient { + return { + ready: true, + requestCustom: vi.fn(async () => ({ + completions: [ + { + text, + displayText, + range: { start: { line: 0, character } }, + position: { line: 0, character }, + uuid, + }, + ], + })), + notifyCustom: vi.fn(), + } as unknown as LanguageServerClient +} + +function createCopilotView({ + doc, + selection, + client, + extensions = [], +}: { + doc: string + selection: number + client: LanguageServerClient + extensions?: Extension[] +}): EditorView { + const kclManager = { copilotEnabled: true } as KclManager + const parent = document.body.appendChild(document.createElement('div')) + + return new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor: selection }, + extensions: [ + docPathFacet.of('/test.kcl'), + languageId.of('kcl'), + ...extensions, + copilotPlugin( + { + client, + documentUri: 'file:///test.kcl', + workspaceFolders: [], + allowHTMLContent: true, + }, + kclManager + ), + ], + }), + parent, + }) +} + +function dispatchInput(view: EditorView, from: number, insert: string) { + view.dispatch({ + changes: { from, insert }, + selection: { anchor: from + insert.length }, + annotations: Transaction.userEvent.of('input.type'), + }) +} + +function dispatchKeydown(view: EditorView, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }) + view.contentDOM.dispatchEvent(event) + return event +} + describe('copilotPlugin', () => { let view: EditorView | null = null @@ -30,24 +113,11 @@ describe('copilotPlugin', () => { it('rejects ghost text before ordinary keys when CodeMirror autocomplete is active', async () => { vi.useFakeTimers() - const client = { - ready: true, - requestCustom: vi.fn(async () => ({ - completions: [ - { - text: '=)', - displayText: '=)', - range: { start: { line: 0, character: 6 } }, - position: { line: 0, character: 6 }, - uuid: 'ghost-equals', - }, - ], - })), - notifyCustom: vi.fn(), - } as unknown as LanguageServerClient - - const kclManager = { copilotEnabled: true } as KclManager - const parent = document.body.appendChild(document.createElement('div')) + const client = createClient({ + text: '=)', + character: 6, + uuid: 'ghost-equals', + }) const explicitCompletionSource = (context: CompletionContext) => { if (!context.explicit) return null const word = context.matchBefore(/\w*/) @@ -57,36 +127,19 @@ describe('copilotPlugin', () => { } } - view = new EditorView({ - state: EditorState.create({ - doc: 'lengt', - selection: { anchor: 5 }, - extensions: [ - docPathFacet.of('/test.kcl'), - languageId.of('kcl'), - autocompletion({ - override: [explicitCompletionSource], - }), - copilotPlugin( - { - client, - documentUri: 'file:///test.kcl', - workspaceFolders: [], - allowHTMLContent: true, - }, - kclManager - ), - ], - }), - parent, + view = createCopilotView({ + doc: 'lengt', + selection: 5, + client, + extensions: [ + autocompletion({ + override: [explicitCompletionSource], + }), + ], }) view.focus() - view.dispatch({ - changes: { from: 5, insert: 'h' }, - selection: { anchor: 6 }, - annotations: Transaction.userEvent.of('input.type'), - }) + dispatchInput(view, 5, 'h') await vi.advanceTimersByTimeAsync(600) expect(view.state.doc.toString()).toBe('length=)') @@ -95,22 +148,13 @@ describe('copilotPlugin', () => { expect(startCompletion(view)).toBe(true) expect(completionStatus(view.state)).not.toBeNull() - const event = new KeyboardEvent('keydown', { - key: '=', - bubbles: true, - cancelable: true, - }) - view.contentDOM.dispatchEvent(event) + const event = dispatchKeydown(view, '=') expect(event.defaultPrevented).toBe(false) expect(view.state.doc.toString()).toBe('length') expect(view.state.selection.main.head).toBe(6) - view.dispatch({ - changes: { from: 6, insert: '=' }, - selection: { anchor: 7 }, - annotations: Transaction.userEvent.of('input.type'), - }) + dispatchInput(view, 6, '=') expect(view.state.doc.toString()).toBe('length=') expect(view.state.selection.main.head).toBe(7) @@ -119,80 +163,64 @@ describe('copilotPlugin', () => { it('rejects punctuation-leading ghost text instead of typing through it', async () => { vi.useFakeTimers() - const client = { - ready: true, - requestCustom: vi.fn(async () => ({ - completions: [ - { - text: ' =)', - displayText: ' =)', - range: { start: { line: 0, character: 3 } }, - position: { line: 0, character: 3 }, - uuid: 'ghost-assignment', - }, - ], - })), - notifyCustom: vi.fn(), - } as unknown as LanguageServerClient - - const kclManager = { copilotEnabled: true } as KclManager - const parent = document.body.appendChild(document.createElement('div')) - - view = new EditorView({ - state: EditorState.create({ - doc: 'fo', - selection: { anchor: 2 }, - extensions: [ - docPathFacet.of('/test.kcl'), - languageId.of('kcl'), - copilotPlugin( - { - client, - documentUri: 'file:///test.kcl', - workspaceFolders: [], - allowHTMLContent: true, - }, - kclManager - ), - ], - }), - parent, + const client = createClient({ + text: ' =)', + character: 3, + uuid: 'ghost-assignment', }) - view.focus() - view.dispatch({ - changes: { from: 2, insert: 'o' }, - selection: { anchor: 3 }, - annotations: Transaction.userEvent.of('input.type'), + view = createCopilotView({ + doc: 'fo', + selection: 2, + client, }) + view.focus() + + dispatchInput(view, 2, 'o') await vi.advanceTimersByTimeAsync(600) expect(view.state.doc.toString()).toBe('foo =)') expect(view.state.selection.main.head).toBe(3) - const spaceEvent = new KeyboardEvent('keydown', { - key: ' ', - bubbles: true, - cancelable: true, - }) - view.contentDOM.dispatchEvent(spaceEvent) + const spaceEvent = dispatchKeydown(view, ' ') expect(spaceEvent.defaultPrevented).toBe(false) expect(view.state.doc.toString()).toBe('foo') expect(view.state.selection.main.head).toBe(3) - view.dispatch({ - changes: { from: 3, insert: ' ' }, - selection: { anchor: 4 }, - annotations: Transaction.userEvent.of('input.type'), - }) - view.dispatch({ - changes: { from: 4, insert: '=' }, - selection: { anchor: 5 }, - annotations: Transaction.userEvent.of('input.type'), - }) + dispatchInput(view, 3, ' ') + dispatchInput(view, 4, '=') expect(view.state.doc.toString()).toBe('foo =') expect(view.state.selection.main.head).toBe(5) }) + + it('keeps type-through behavior for word-leading ghost text', async () => { + vi.useFakeTimers() + + const client = createClient({ + text: 'bar', + character: 3, + uuid: 'ghost-word', + }) + + view = createCopilotView({ + doc: 'fo', + selection: 2, + client, + }) + view.focus() + + dispatchInput(view, 2, 'o') + await vi.advanceTimersByTimeAsync(600) + + expect(view.state.doc.toString()).toBe('foobar') + expect(view.state.selection.main.head).toBe(3) + + const event = dispatchKeydown(view, 'b') + + expect(event.defaultPrevented).toBe(true) + expect(view.state.doc.toString()).toBe('foobar') + expect(view.state.selection.main.head).toBe(4) + }) }) diff --git a/src/editor/plugins/lsp/copilot/index.ts b/src/editor/plugins/lsp/copilot/index.ts index 7aee63b5e02..9058ae150b8 100644 --- a/src/editor/plugins/lsp/copilot/index.ts +++ b/src/editor/plugins/lsp/copilot/index.ts @@ -81,7 +81,7 @@ interface GhostText { uuid: string } -const typeThroughCharacter = /^[A-Za-z0-9_]$/ +const wordLikeTypeThroughCharacter = /^[A-Za-z0-9_]$/ function canTypeThroughGhostText(key: string, displayText: string): boolean { if (key.length !== 1) { @@ -90,9 +90,9 @@ function canTypeThroughGhostText(key: string, displayText: string): boolean { const nextGhostChar = displayText[0] return ( - typeThroughCharacter.test(key) && + wordLikeTypeThroughCharacter.test(key) && nextGhostChar !== undefined && - typeThroughCharacter.test(nextGhostChar) + wordLikeTypeThroughCharacter.test(nextGhostChar) ) } From 2979a7f05d354db95ffae7ec85cc86cb0e1e686c Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Thu, 2 Jul 2026 17:07:31 -0400 Subject: [PATCH 3/5] Lint --- src/editor/plugins/lsp/copilot/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/plugins/lsp/copilot/index.test.ts b/src/editor/plugins/lsp/copilot/index.test.ts index d5e79caa875..9dea925dc22 100644 --- a/src/editor/plugins/lsp/copilot/index.test.ts +++ b/src/editor/plugins/lsp/copilot/index.test.ts @@ -15,7 +15,7 @@ import { } from '@kittycad/codemirror-lsp-client' import type { KclManager } from '@src/lang/KclManager' -import { copilotPlugin } from './index' +import { copilotPlugin } from '@src/editor/plugins/lsp/copilot' function createClient({ text, From d813eaa77100d787efae23f450c835003e57ff1d Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Thu, 2 Jul 2026 19:36:26 -0400 Subject: [PATCH 4/5] Fix typos --- src/editor/plugins/lsp/copilot/index.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/editor/plugins/lsp/copilot/index.test.ts b/src/editor/plugins/lsp/copilot/index.test.ts index 9dea925dc22..23a19b9264c 100644 --- a/src/editor/plugins/lsp/copilot/index.test.ts +++ b/src/editor/plugins/lsp/copilot/index.test.ts @@ -128,8 +128,8 @@ describe('copilotPlugin', () => { } view = createCopilotView({ - doc: 'lengt', - selection: 5, + doc: '', + selection: 0, client, extensions: [ autocompletion({ @@ -139,7 +139,7 @@ describe('copilotPlugin', () => { }) view.focus() - dispatchInput(view, 5, 'h') + dispatchInput(view, 0, 'length') await vi.advanceTimersByTimeAsync(600) expect(view.state.doc.toString()).toBe('length=)') @@ -170,13 +170,13 @@ describe('copilotPlugin', () => { }) view = createCopilotView({ - doc: 'fo', - selection: 2, + doc: '', + selection: 0, client, }) view.focus() - dispatchInput(view, 2, 'o') + dispatchInput(view, 0, 'foo') await vi.advanceTimersByTimeAsync(600) expect(view.state.doc.toString()).toBe('foo =)') @@ -205,13 +205,13 @@ describe('copilotPlugin', () => { }) view = createCopilotView({ - doc: 'fo', - selection: 2, + doc: '', + selection: 0, client, }) view.focus() - dispatchInput(view, 2, 'o') + dispatchInput(view, 0, 'foo') await vi.advanceTimersByTimeAsync(600) expect(view.state.doc.toString()).toBe('foobar') From bce9d27b36e01fe2e145734e07a5c1eaf4c129d1 Mon Sep 17 00:00:00 2001 From: Pierre Jacquier Date: Thu, 2 Jul 2026 21:19:22 -0400 Subject: [PATCH 5/5] Clean up for review --- src/editor/plugins/lsp/copilot/index.test.ts | 55 +++++++++++++------- src/editor/plugins/lsp/copilot/index.ts | 13 ++++- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/editor/plugins/lsp/copilot/index.test.ts b/src/editor/plugins/lsp/copilot/index.test.ts index 23a19b9264c..b3c322e6980 100644 --- a/src/editor/plugins/lsp/copilot/index.test.ts +++ b/src/editor/plugins/lsp/copilot/index.test.ts @@ -17,6 +17,8 @@ import type { KclManager } from '@src/lang/KclManager' import { copilotPlugin } from '@src/editor/plugins/lsp/copilot' +const copilotRequestDelayMs = 600 + function createClient({ text, displayText = text, @@ -28,6 +30,8 @@ function createClient({ character: number uuid: string }): LanguageServerClient { + // Copilot returns text for acceptance, but the plugin immediately inserts + // displayText into the document and decorates it as ghost text. return { ready: true, requestCustom: vi.fn(async () => ({ @@ -83,6 +87,8 @@ function createCopilotView({ } function dispatchInput(view: EditorView, from: number, insert: string) { + // These tests dispatch keydown and input separately because JSDOM does not + // synthesize browser text input after a non-prevented keydown. view.dispatch({ changes: { from, insert }, selection: { anchor: from + insert.length }, @@ -100,6 +106,10 @@ function dispatchKeydown(view: EditorView, key: string): KeyboardEvent { return event } +async function waitForCopilotSuggestion() { + await vi.advanceTimersByTimeAsync(copilotRequestDelayMs) +} + describe('copilotPlugin', () => { let view: EditorView | null = null @@ -110,12 +120,15 @@ describe('copilotPlugin', () => { vi.useRealTimers() }) + // Regression coverage for https://github.com/KittyCAD/modeling-app/issues/12133, + // where Copilot ghost text swallowed ` = ` while editing function arguments. it('rejects ghost text before ordinary keys when CodeMirror autocomplete is active', async () => { vi.useFakeTimers() + const typedText = 'length' const client = createClient({ text: '=)', - character: 6, + character: typedText.length, uuid: 'ghost-equals', }) const explicitCompletionSource = (context: CompletionContext) => { @@ -139,11 +152,11 @@ describe('copilotPlugin', () => { }) view.focus() - dispatchInput(view, 0, 'length') - await vi.advanceTimersByTimeAsync(600) + dispatchInput(view, 0, typedText) + await waitForCopilotSuggestion() expect(view.state.doc.toString()).toBe('length=)') - expect(view.state.selection.main.head).toBe(6) + expect(view.state.selection.main.head).toBe(typedText.length) expect(startCompletion(view)).toBe(true) expect(completionStatus(view.state)).not.toBeNull() @@ -152,20 +165,21 @@ describe('copilotPlugin', () => { expect(event.defaultPrevented).toBe(false) expect(view.state.doc.toString()).toBe('length') - expect(view.state.selection.main.head).toBe(6) + expect(view.state.selection.main.head).toBe(typedText.length) - dispatchInput(view, 6, '=') + dispatchInput(view, typedText.length, '=') expect(view.state.doc.toString()).toBe('length=') - expect(view.state.selection.main.head).toBe(7) + expect(view.state.selection.main.head).toBe(typedText.length + 1) }) it('rejects punctuation-leading ghost text instead of typing through it', async () => { vi.useFakeTimers() + const typedText = 'foo' const client = createClient({ text: ' =)', - character: 3, + character: typedText.length, uuid: 'ghost-assignment', }) @@ -176,31 +190,32 @@ describe('copilotPlugin', () => { }) view.focus() - dispatchInput(view, 0, 'foo') - await vi.advanceTimersByTimeAsync(600) + dispatchInput(view, 0, typedText) + await waitForCopilotSuggestion() expect(view.state.doc.toString()).toBe('foo =)') - expect(view.state.selection.main.head).toBe(3) + expect(view.state.selection.main.head).toBe(typedText.length) const spaceEvent = dispatchKeydown(view, ' ') expect(spaceEvent.defaultPrevented).toBe(false) expect(view.state.doc.toString()).toBe('foo') - expect(view.state.selection.main.head).toBe(3) + expect(view.state.selection.main.head).toBe(typedText.length) - dispatchInput(view, 3, ' ') - dispatchInput(view, 4, '=') + dispatchInput(view, typedText.length, ' ') + dispatchInput(view, typedText.length + 1, '=') expect(view.state.doc.toString()).toBe('foo =') - expect(view.state.selection.main.head).toBe(5) + expect(view.state.selection.main.head).toBe(typedText.length + 2) }) it('keeps type-through behavior for word-leading ghost text', async () => { vi.useFakeTimers() + const typedText = 'foo' const client = createClient({ text: 'bar', - character: 3, + character: typedText.length, uuid: 'ghost-word', }) @@ -211,16 +226,16 @@ describe('copilotPlugin', () => { }) view.focus() - dispatchInput(view, 0, 'foo') - await vi.advanceTimersByTimeAsync(600) + dispatchInput(view, 0, typedText) + await waitForCopilotSuggestion() expect(view.state.doc.toString()).toBe('foobar') - expect(view.state.selection.main.head).toBe(3) + expect(view.state.selection.main.head).toBe(typedText.length) const event = dispatchKeydown(view, 'b') expect(event.defaultPrevented).toBe(true) expect(view.state.doc.toString()).toBe('foobar') - expect(view.state.selection.main.head).toBe(4) + expect(view.state.selection.main.head).toBe(typedText.length + 1) }) }) diff --git a/src/editor/plugins/lsp/copilot/index.ts b/src/editor/plugins/lsp/copilot/index.ts index 9058ae150b8..eb3287f720f 100644 --- a/src/editor/plugins/lsp/copilot/index.ts +++ b/src/editor/plugins/lsp/copilot/index.ts @@ -81,6 +81,10 @@ interface GhostText { uuid: string } +// Ghost text is inserted into the real document. Type-through consumes the +// keydown and advances through that inserted text, so keep it to identifier-like +// continuations. Space/punctuation suggestions should be rejected and let the +// editor insert the user's actual keypress, e.g. typing ` = ` in an argument. const wordLikeTypeThroughCharacter = /^[A-Za-z0-9_]$/ function canTypeThroughGhostText(key: string, displayText: string): boolean { @@ -526,11 +530,16 @@ export class CompletionRequester implements PluginValue { const tabKey = 'Tab' + // CodeMirror completions and Copilot ghost text can overlap briefly while a + // snippet placeholder is being edited. Prefer the explicit completion flow + // so Copilot cannot consume the next typed key. if (this.autocompleting()) { return this.rejectSuggestionCommand() } - // 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 + // For accepted type-through keys, leave the ghost text in the document and + // move the selection past the typed prefix so the browser does not insert a + // duplicate character. const ghostTextStart = ghostText.displayPos const indent = this.view.state.facet(indentUnit) @@ -550,7 +559,7 @@ export class CompletionRequester implements PluginValue { } else if (ghostText.displayText.length === 1) { return this.acceptSuggestionCommand() } else { - // Use this to delete the first letter of the suggestion + // Advance through the first ghost character that matches the typed key. this.view.dispatch({ selection: { anchor: ghostTextStart + 1 }, effects: typeFirst.of(1),