mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
feat(vscode): support Jupyter notebook autocomplete
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Enable autocomplete in Jupyter notebooks.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all other
|
||||
commercial damages or losses), even if such Contributor has been
|
||||
advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Continue Dev, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -15,6 +15,7 @@ import { previewSound } from "./services/attention"
|
||||
import type { EditorContext, IndexingStatus } from "./services/cli-backend/types"
|
||||
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
|
||||
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
|
||||
import { notebookUri } from "./services/autocomplete/continuedev/core/autocomplete/notebook"
|
||||
import { buildWebviewHtml, getWebviewFontSize } from "./utils"
|
||||
import { saveImage } from "./kilo-provider/save-image"
|
||||
import { handleEditorAction } from "./kilo-provider/editor-actions"
|
||||
@@ -3432,14 +3433,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const result = new Set<string>()
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.input instanceof vscode.TabInputText) {
|
||||
const uri = tab.input.uri
|
||||
if (uri.scheme === "file") {
|
||||
const rel = path.relative(dir, uri.fsPath)
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
|
||||
result.add(rel.replaceAll("\\", "/"))
|
||||
}
|
||||
}
|
||||
const uri =
|
||||
tab.input instanceof vscode.TabInputText || tab.input instanceof vscode.TabInputNotebook
|
||||
? tab.input.uri
|
||||
: undefined
|
||||
if (uri?.scheme !== "file") continue
|
||||
|
||||
const rel = path.relative(dir, uri.fsPath)
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
|
||||
result.add(rel.replaceAll("\\", "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3477,21 +3479,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
// Visible files (capped to avoid bloating context, filtered through .kilocodeignore)
|
||||
const visibleFiles = vscode.window.visibleTextEditors
|
||||
.map((e) => e.document.uri)
|
||||
.filter((uri) => uri.scheme === "file")
|
||||
.map((uri) => toRelative(uri.fsPath))
|
||||
.filter((p): p is string => p !== undefined && controller.validateAccess(path.resolve(workspaceDir, p)))
|
||||
.slice(0, 200)
|
||||
const visibleFiles = [
|
||||
...new Set(
|
||||
[
|
||||
...vscode.window.visibleTextEditors.map((editor) => notebookUri(editor.document.uri)),
|
||||
...vscode.window.visibleNotebookEditors.map((editor) => editor.notebook.uri),
|
||||
]
|
||||
.filter((uri): uri is vscode.Uri => uri?.scheme === "file")
|
||||
.map((uri) => toRelative(uri.fsPath))
|
||||
.filter(
|
||||
(file): file is string => file !== undefined && controller.validateAccess(path.resolve(workspaceDir, file)),
|
||||
),
|
||||
),
|
||||
].slice(0, 200)
|
||||
|
||||
// Open tabs — use instanceof TabInputText to exclude notebooks, diffs, custom editors
|
||||
// Open tabs — text and notebook files only; exclude diffs and custom editors
|
||||
const openTabs = [...(await this.getOpenTabPaths(workspaceDir))].slice(0, 20)
|
||||
|
||||
// Active file (also filtered through .kilocodeignore)
|
||||
const activeEditor = vscode.window.activeTextEditor
|
||||
const activeRel =
|
||||
activeEditor?.document.uri.scheme === "file" ? toRelative(activeEditor.document.uri.fsPath) : undefined
|
||||
const activeFile = activeRel && controller.validateAccess(activeEditor!.document.uri.fsPath) ? activeRel : undefined
|
||||
const activeUri = activeEditor
|
||||
? notebookUri(activeEditor.document.uri)
|
||||
: vscode.window.activeNotebookEditor?.notebook.uri
|
||||
const activeRel = activeUri ? toRelative(activeUri.fsPath) : undefined
|
||||
const activeFile = activeRel && activeUri && controller.validateAccess(activeUri.fsPath) ? activeRel : undefined
|
||||
|
||||
// Shell
|
||||
const shell = vscode.env.shell || undefined
|
||||
|
||||
@@ -12,10 +12,24 @@ import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager
|
||||
import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
import { hasValidCredentials } from "./fim"
|
||||
import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models"
|
||||
import {
|
||||
DEFAULT_AUTOCOMPLETE_MODEL,
|
||||
getAutocompleteModel,
|
||||
getAutocompleteModelById,
|
||||
} from "../../shared/autocomplete-models"
|
||||
|
||||
const CONFIG_SECTION = "kilo-code.new.autocomplete"
|
||||
|
||||
export function selector(kind: "classic" | "next-edit"): vscode.DocumentSelector {
|
||||
return kind === "classic" ? [{ scheme: "file" }, { scheme: "vscode-notebook-cell" }] : [{ scheme: "file" }]
|
||||
}
|
||||
|
||||
export function notebookModel(provider?: string, model?: string) {
|
||||
const info = getAutocompleteModel(provider, model)
|
||||
if (info.kind !== "edit") return info
|
||||
return getAutocompleteModelById(info.fimModelID)
|
||||
}
|
||||
|
||||
export interface AutocompleteServiceSettings {
|
||||
enableAutoTrigger?: boolean
|
||||
enableSmartInlineTaskKeybinding?: boolean
|
||||
@@ -68,6 +82,7 @@ export class AutocompleteServiceManager {
|
||||
public readonly nextEditProvider: NextEditInlineCompletionProvider
|
||||
public readonly nextEditSuggestionManager: NextEditSuggestionManager
|
||||
private inlineCompletionProviderDisposable: vscode.Disposable | null = null
|
||||
private notebookCompletionProviderDisposable: vscode.Disposable | null = null
|
||||
private inlineCompletionProviderKind: "classic" | "next-edit" | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
private unsubscribeEvent: (() => void) | null = null
|
||||
@@ -177,7 +192,7 @@ export class AutocompleteServiceManager {
|
||||
public async load() {
|
||||
this.settings = readSettings()
|
||||
|
||||
this.inlineCompletionProvider.setModel(getAutocompleteModel(this.settings.provider, this.settings.model).id)
|
||||
this.inlineCompletionProvider.setModel(notebookModel(this.settings.provider, this.settings.model).id)
|
||||
|
||||
await this.updateGlobalContext()
|
||||
this.updateStatusBar()
|
||||
@@ -204,7 +219,9 @@ export class AutocompleteServiceManager {
|
||||
) {
|
||||
if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear()
|
||||
this.inlineCompletionProviderDisposable?.dispose()
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
}
|
||||
|
||||
@@ -216,7 +233,9 @@ export class AutocompleteServiceManager {
|
||||
|
||||
if (!shouldBeRegistered) {
|
||||
this.inlineCompletionProviderDisposable!.dispose()
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
return
|
||||
}
|
||||
@@ -224,9 +243,15 @@ export class AutocompleteServiceManager {
|
||||
const provider: vscode.InlineCompletionItemProvider =
|
||||
desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider
|
||||
this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
|
||||
{ scheme: "file" },
|
||||
selector(desiredKind),
|
||||
provider,
|
||||
)
|
||||
if (desiredKind === "next-edit") {
|
||||
this.notebookCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
|
||||
[{ scheme: "vscode-notebook-cell" }],
|
||||
this.inlineCompletionProvider,
|
||||
)
|
||||
}
|
||||
this.inlineCompletionProviderKind = desiredKind
|
||||
}
|
||||
|
||||
@@ -459,6 +484,8 @@ export class AutocompleteServiceManager {
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
}
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
|
||||
// Dispose inline completion provider resources
|
||||
this.inlineCompletionProvider.dispose()
|
||||
|
||||
+30
-1
@@ -251,12 +251,41 @@ describe("AutocompleteServiceManager (less mocked logic)", () => {
|
||||
await (manager as any).ensureInlineCompletionProviderRegistration()
|
||||
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenCalledWith(
|
||||
{ scheme: "file" },
|
||||
[{ scheme: "file" }, { scheme: "vscode-notebook-cell" }],
|
||||
manager.inlineCompletionProvider,
|
||||
)
|
||||
expect((manager as any).inlineCompletionProviderDisposable).toBe(disposable)
|
||||
})
|
||||
|
||||
it("registers classic notebook fallback when Next Edit is selected", async () => {
|
||||
const manager = await createManager()
|
||||
|
||||
const primary = { dispose: vi.fn() }
|
||||
const notebook = { dispose: vi.fn() }
|
||||
vi.mocked(vscode.languages.registerInlineCompletionItemProvider)
|
||||
.mockReturnValueOnce(primary as any)
|
||||
.mockReturnValueOnce(notebook as any)
|
||||
;(manager as any).settings = {
|
||||
enableAutoTrigger: true,
|
||||
provider: "kilo",
|
||||
model: "inception/mercury-next-edit",
|
||||
}
|
||||
|
||||
await (manager as any).ensureInlineCompletionProviderRegistration()
|
||||
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ scheme: "file" }],
|
||||
manager.nextEditProvider,
|
||||
)
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ scheme: "vscode-notebook-cell" }],
|
||||
manager.inlineCompletionProvider,
|
||||
)
|
||||
expect((manager as any).notebookCompletionProviderDisposable).toBe(notebook)
|
||||
})
|
||||
|
||||
it("does not register the provider when snoozed", async () => {
|
||||
const manager = await createManager()
|
||||
|
||||
|
||||
+50
-41
@@ -15,6 +15,7 @@ import {
|
||||
} from "../types"
|
||||
import {
|
||||
findMatchingSuggestion as _findMatchingSuggestion,
|
||||
findCoveringPendingRequest,
|
||||
applyFirstLineOnly as _applyFirstLineOnly,
|
||||
calcDebounceDelay,
|
||||
MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn,
|
||||
@@ -32,10 +33,21 @@ import { postprocessAutocompleteSuggestion } from "./uselessSuggestionFilter"
|
||||
import { shouldSkipAutocomplete } from "./contextualSkip"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
import { AutocompleteTelemetry } from "./AutocompleteTelemetry"
|
||||
import {
|
||||
autocompleteScope,
|
||||
getNotebookContext,
|
||||
notebookUri,
|
||||
supportsNotebook,
|
||||
} from "../continuedev/core/autocomplete/notebook"
|
||||
import { ErrorBackoff } from "./ErrorBackoff"
|
||||
|
||||
const MAX_SUGGESTIONS_HISTORY = 20
|
||||
|
||||
export function accessible(controller: FileIgnoreController, document: vscode.TextDocument): boolean {
|
||||
const uri = notebookUri(document.uri)
|
||||
return controller.validateAccess(uri?.fsPath ?? document.fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum debounce delay in milliseconds.
|
||||
* The adaptive debounce delay will never go below this value, even when
|
||||
@@ -70,11 +82,12 @@ export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult
|
||||
export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn
|
||||
|
||||
export function findMatchingSuggestion(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
return _findMatchingSuggestion(prefix, suffix, suggestionsHistory)
|
||||
return _findMatchingSuggestion(scope, prefix, suffix, suggestionsHistory)
|
||||
}
|
||||
|
||||
export function applyFirstLineOnly(
|
||||
@@ -178,6 +191,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
public updateSuggestions(fillInAtCursor: FillInAtCursorSuggestion): void {
|
||||
const isDuplicate = this.suggestionsHistory.some(
|
||||
(existing) =>
|
||||
existing.scope === fillInAtCursor.scope &&
|
||||
existing.text === fillInAtCursor.text &&
|
||||
existing.prefix === fillInAtCursor.prefix &&
|
||||
existing.suffix === fillInAtCursor.suffix,
|
||||
@@ -211,7 +225,16 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
recentlyEditedRanges,
|
||||
}
|
||||
|
||||
const autocompleteInput = contextToAutocompleteInput(context)
|
||||
const input = contextToAutocompleteInput(context)
|
||||
const notebook = getNotebookContext(document, position)
|
||||
const autocompleteInput = notebook
|
||||
? {
|
||||
...input,
|
||||
filepath: notebook.filepath,
|
||||
pos: { line: notebook.position.line, character: notebook.position.character },
|
||||
manuallyPassFileContents: notebook.contents,
|
||||
}
|
||||
: input
|
||||
|
||||
const { prefix, suffix } = extractPrefixSuffix(document, position)
|
||||
const languageId = document.languageId
|
||||
@@ -235,6 +258,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
private processSuggestion(
|
||||
suggestionText: string,
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
telemetryContext: AutocompleteContext,
|
||||
@@ -242,7 +266,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
): FillInAtCursorSuggestion {
|
||||
if (!suggestionText) {
|
||||
this.telemetry?.captureSuggestionFiltered("empty_response", telemetryContext)
|
||||
return { text: "", prefix, suffix }
|
||||
return { text: "", scope, prefix, suffix }
|
||||
}
|
||||
|
||||
const processedText = postprocessAutocompleteSuggestion({
|
||||
@@ -254,11 +278,11 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
})
|
||||
|
||||
if (processedText) {
|
||||
return { text: processedText, prefix, suffix }
|
||||
return { text: processedText, scope, prefix, suffix }
|
||||
}
|
||||
|
||||
this.telemetry?.captureSuggestionFiltered("filtered_by_postprocessing", telemetryContext)
|
||||
return { text: "", prefix, suffix }
|
||||
return { text: "", scope, prefix, suffix }
|
||||
}
|
||||
|
||||
private async disposeIgnoreController(): Promise<void> {
|
||||
@@ -336,6 +360,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList> {
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.autocomplete.hasSuggestions", false)
|
||||
if (!supportsNotebook(document)) return []
|
||||
|
||||
// Build telemetry context
|
||||
const telemetryContext: AutocompleteContext = {
|
||||
@@ -389,8 +414,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
return []
|
||||
}
|
||||
|
||||
const isAccessible = controller.validateAccess(document.fileName)
|
||||
if (!isAccessible) {
|
||||
if (!accessible(controller, document)) {
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
@@ -399,10 +423,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
}
|
||||
|
||||
const scope = autocompleteScope(document)
|
||||
const { prefix, suffix } = extractPrefixSuffix(document, position)
|
||||
|
||||
// Check cache first - allow mid-word lookups from cache
|
||||
const matchingResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix)
|
||||
const matchingResult = applyFirstLineOnly(
|
||||
findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory),
|
||||
prefix,
|
||||
)
|
||||
|
||||
if (matchingResult !== null) {
|
||||
this.lastSuggestion = {
|
||||
@@ -425,9 +453,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
const { prompt, prefix: promptPrefix, suffix: promptSuffix } = await this.getPrompt(document, position)
|
||||
|
||||
await this.debouncedFetchAndCacheSuggestion(prompt, promptPrefix, promptSuffix, document.languageId)
|
||||
await this.debouncedFetchAndCacheSuggestion(scope, prompt, promptPrefix, promptSuffix, document.languageId)
|
||||
|
||||
const cachedResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix)
|
||||
const cachedResult = applyFirstLineOnly(
|
||||
findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory),
|
||||
prefix,
|
||||
)
|
||||
if (cachedResult) {
|
||||
this.lastSuggestion = {
|
||||
...telemetryContext,
|
||||
@@ -448,31 +479,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending request that covers the current prefix/suffix.
|
||||
* A request covers the current position if:
|
||||
* 1. The suffix matches (user hasn't changed text after cursor)
|
||||
* 2. The current prefix either equals or extends the pending prefix
|
||||
* (user is typing forward, not backspacing or editing earlier)
|
||||
*
|
||||
* @returns The covering pending request, or null if none found
|
||||
*/
|
||||
private findCoveringPendingRequest(prefix: string, suffix: string): PendingRequest | null {
|
||||
for (const pendingRequest of this.pendingRequests) {
|
||||
// Suffix must match exactly (text after cursor unchanged)
|
||||
if (suffix !== pendingRequest.suffix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Current prefix must start with the pending prefix (user typed more)
|
||||
// or be exactly equal (same position)
|
||||
if (prefix.startsWith(pendingRequest.prefix)) {
|
||||
return pendingRequest
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending request from the list when it completes.
|
||||
*/
|
||||
@@ -499,13 +505,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
* - If a pending request covers the current prefix/suffix, reuse it instead of starting a new one
|
||||
*/
|
||||
private debouncedFetchAndCacheSuggestion(
|
||||
scope: string,
|
||||
prompt: AutocompletePrompt,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
languageId: string,
|
||||
): Promise<void> {
|
||||
// Check if any existing pending request covers this one
|
||||
const coveringRequest = this.findCoveringPendingRequest(prefix, suffix)
|
||||
const coveringRequest = findCoveringPendingRequest(scope, prefix, suffix, this.pendingRequests)
|
||||
if (coveringRequest) {
|
||||
// Wait for the existing request to complete - no need to start a new one
|
||||
return coveringRequest.promise
|
||||
@@ -515,8 +522,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
// but still track it as a pending request so subsequent calls can reuse it
|
||||
if (this.isFirstCall && this.debounceTimer === null) {
|
||||
this.isFirstCall = false
|
||||
const promise = this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId)
|
||||
const leading: PendingRequest = { prefix, suffix, promise }
|
||||
const promise = this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId)
|
||||
const leading: PendingRequest = { scope, prefix, suffix, promise }
|
||||
promise.finally(() => this.removePendingRequest(leading))
|
||||
this.pendingRequests.push(leading)
|
||||
return promise
|
||||
@@ -533,6 +540,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
// Create the pending request object first so we can reference it in the cleanup
|
||||
const pendingRequest: PendingRequest = {
|
||||
scope,
|
||||
prefix,
|
||||
suffix,
|
||||
promise: null!, // Will be set immediately below
|
||||
@@ -545,7 +553,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
this.debouncedPendingRequest = null
|
||||
this.isFirstCall = true // Reset for next sequence
|
||||
try {
|
||||
await this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId)
|
||||
await this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId)
|
||||
} finally {
|
||||
this.removePendingRequest(pendingRequest)
|
||||
resolve()
|
||||
@@ -566,6 +574,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
|
||||
public async fetchAndCacheSuggestion(
|
||||
scope: string,
|
||||
prompt: AutocompletePrompt,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
@@ -592,9 +601,9 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
|
||||
try {
|
||||
// Curry processSuggestion with prefix, suffix, telemetry context, and languageId
|
||||
// Curry processSuggestion with request context
|
||||
const curriedProcessSuggestion = (text: string) =>
|
||||
this.processSuggestion(text, prefix, suffix, telemetryContext, languageId)
|
||||
this.processSuggestion(text, scope, prefix, suffix, telemetryContext, languageId)
|
||||
|
||||
const result = await this.fimPromptBuilder.getFromFIM(
|
||||
this.connectionService,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ describe("AutocompleteTelemetry", () => {
|
||||
function createSuggestion(index: number): FillInAtCursorSuggestion {
|
||||
return {
|
||||
text: `text-${index}`,
|
||||
scope: `scope-${index}`,
|
||||
prefix: `prefix-${index}`,
|
||||
suffix: `suffix-${index}`,
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types"
|
||||
import type { FillInAtCursorSuggestion, MatchingSuggestionResult, PendingRequest } from "../types"
|
||||
|
||||
export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult {
|
||||
fillInAtCursor: FillInAtCursorSuggestion
|
||||
@@ -12,12 +12,14 @@ const MAX_DEBOUNCE_DELAY_MS = 1000
|
||||
* Searches from most recent to least recent.
|
||||
*/
|
||||
export function findMatchingSuggestion(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
for (let i = suggestionsHistory.length - 1; i >= 0; i--) {
|
||||
const fillInAtCursor = suggestionsHistory[i]!
|
||||
if (scope !== fillInAtCursor.scope) continue
|
||||
|
||||
if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) {
|
||||
return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor }
|
||||
@@ -42,6 +44,19 @@ export function findMatchingSuggestion(
|
||||
return null
|
||||
}
|
||||
|
||||
export function findCoveringPendingRequest(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
requests: PendingRequest[],
|
||||
): PendingRequest | null {
|
||||
for (const request of requests) {
|
||||
if (scope !== request.scope || suffix !== request.suffix) continue
|
||||
if (prefix.startsWith(request.prefix)) return request
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of lines in a text string.
|
||||
* A single trailing newline does not count as an additional line.
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
// Copied from Continue's VS Code autocomplete provider:
|
||||
// https://github.com/continuedev/continue/blob/d0a3c0b626b5bebc3bef4742eec05a0242be0bab/extensions/vscode/src/autocomplete/completionProvider.ts#L226-L263
|
||||
// Copyright 2023 Continue
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
// Modified by Kilo Code for notebook paths, cursor positions, and cache scoping.
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export interface NotebookContext {
|
||||
contents: string
|
||||
filepath: string
|
||||
position: vscode.Position
|
||||
}
|
||||
|
||||
interface NotebookResolution {
|
||||
notebook: vscode.NotebookDocument
|
||||
cells: vscode.NotebookCell[]
|
||||
cell: vscode.NotebookCell
|
||||
index: number
|
||||
version: number
|
||||
}
|
||||
|
||||
const resolutions = new WeakMap<vscode.Uri, NotebookResolution>()
|
||||
|
||||
function resolveNotebook(uri: vscode.Uri): NotebookResolution | undefined {
|
||||
const id = uri.toString()
|
||||
const cached = resolutions.get(uri)
|
||||
if (
|
||||
cached &&
|
||||
cached.notebook.version === cached.version &&
|
||||
vscode.workspace.notebookDocuments.includes(cached.notebook)
|
||||
) {
|
||||
return cached
|
||||
}
|
||||
|
||||
resolutions.delete(uri)
|
||||
for (const notebook of vscode.workspace.notebookDocuments) {
|
||||
const cells = notebook.getCells()
|
||||
const index = cells.findIndex((cell) => cell.document.uri.toString() === id)
|
||||
if (index < 0) continue
|
||||
const resolved = { notebook, cells, cell: cells[index]!, index, version: notebook.version }
|
||||
resolutions.set(uri, resolved)
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function notebookUri(uri: vscode.Uri): vscode.Uri | undefined {
|
||||
if (uri.scheme === "file") return uri
|
||||
if (uri.scheme !== "vscode-notebook-cell") return
|
||||
return resolveNotebook(uri)?.notebook.uri
|
||||
}
|
||||
|
||||
export function supportsNotebook(document: vscode.TextDocument): boolean {
|
||||
if (document.uri.scheme !== "vscode-notebook-cell") return true
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
return resolved?.cell.kind === vscode.NotebookCellKind.Code && document.languageId === "python"
|
||||
}
|
||||
|
||||
export function autocompleteScope(document: vscode.TextDocument): string {
|
||||
const id = document.uri.toString()
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
if (!resolved) return id
|
||||
|
||||
const siblings = resolved.cells
|
||||
.filter((_, index) => index !== resolved.index)
|
||||
.map((cell) => [cell.document.uri.toString(), cell.kind, cell.document.languageId, cell.document.version])
|
||||
return JSON.stringify([id, resolved.notebook.uri.toString(), resolved.index, siblings])
|
||||
}
|
||||
|
||||
export function getNotebookContext(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
): NotebookContext | undefined {
|
||||
if (document.uri.scheme !== "vscode-notebook-cell" || !supportsNotebook(document)) return
|
||||
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
if (!resolved) return
|
||||
|
||||
const cells = resolved.cells
|
||||
const contents = cells
|
||||
.map((cell) => {
|
||||
const text = cell.document.getText()
|
||||
if (cell.kind === vscode.NotebookCellKind.Markup) {
|
||||
return `"""${text}"""`
|
||||
}
|
||||
return text
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
const line = cells
|
||||
.slice(0, resolved.index)
|
||||
.reduce((line, cell) => line + cell.document.getText().split("\n").length + 1, position.line)
|
||||
|
||||
return {
|
||||
contents,
|
||||
filepath: resolved.notebook.uri.fsPath,
|
||||
position: new vscode.Position(line, position.character),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export interface FillInAtCursorSuggestion {
|
||||
text: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export interface MatchingSuggestionResult {
|
||||
@@ -105,6 +106,7 @@ export interface LastSuggestionInfo extends AutocompleteContext {
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
scope: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
promise: Promise<void>
|
||||
|
||||
@@ -47,6 +47,7 @@ const mockVscode = {
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/repo" } }],
|
||||
textDocuments: [] as Array<unknown>,
|
||||
notebookDocuments: [] as Array<unknown>,
|
||||
onDidOpenTextDocument: () => ({ dispose: noop }),
|
||||
onDidChangeTextDocument: () => ({ dispose: noop }),
|
||||
onDidCloseTextDocument: () => ({ dispose: noop }),
|
||||
@@ -73,7 +74,9 @@ const mockVscode = {
|
||||
},
|
||||
window: {
|
||||
activeTextEditor: undefined,
|
||||
activeNotebookEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
visibleNotebookEditors: [],
|
||||
tabGroups: { all: [] },
|
||||
showTextDocument: async () => {},
|
||||
showWarningMessage: async () => undefined,
|
||||
@@ -132,6 +135,10 @@ const mockVscode = {
|
||||
TabInputText: class {
|
||||
constructor(public uri: { scheme: string; fsPath: string }) {}
|
||||
},
|
||||
TabInputNotebook: class {
|
||||
constructor(public uri: { scheme: string; fsPath: string }) {}
|
||||
},
|
||||
NotebookCellKind: { Markup: 1, Code: 2 },
|
||||
Position: class {
|
||||
constructor(
|
||||
public line: number,
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
shouldShowOnlyFirstLine,
|
||||
getFirstLine,
|
||||
calcDebounceDelay,
|
||||
findCoveringPendingRequest,
|
||||
} from "../../src/services/autocomplete/classic-auto-complete/inline-utils"
|
||||
import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types"
|
||||
import type { FillInAtCursorSuggestion, PendingRequest } from "../../src/services/autocomplete/types"
|
||||
|
||||
function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion {
|
||||
return { prefix, suffix, text }
|
||||
const scope = "file:///workspace/file.ts"
|
||||
|
||||
function makeSuggestion(prefix: string, text: string, suffix = "", value = scope): FillInAtCursorSuggestion {
|
||||
return { scope: value, prefix, suffix, text }
|
||||
}
|
||||
|
||||
describe("countLines", () => {
|
||||
@@ -89,46 +92,68 @@ describe("shouldShowOnlyFirstLine", () => {
|
||||
|
||||
describe("findMatchingSuggestion", () => {
|
||||
it("returns null for empty history", () => {
|
||||
expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull()
|
||||
expect(findMatchingSuggestion(scope, "prefix", "suffix", [])).toBeNull()
|
||||
})
|
||||
|
||||
it("returns exact match", () => {
|
||||
const hist = [makeSuggestion("hello ", "world")]
|
||||
const result = findMatchingSuggestion("hello ", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "hello ", "", hist)
|
||||
expect(result?.matchType).toBe("exact")
|
||||
expect(result?.text).toBe("world")
|
||||
})
|
||||
|
||||
it("returns partial_typing match when user typed beginning of suggestion", () => {
|
||||
const hist = [makeSuggestion("he", "llo world")]
|
||||
const result = findMatchingSuggestion("hell", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "hell", "", hist)
|
||||
expect(result?.matchType).toBe("partial_typing")
|
||||
expect(result?.text).toBe("o world")
|
||||
})
|
||||
|
||||
it("returns backward_deletion match when user deleted chars", () => {
|
||||
const hist = [makeSuggestion("hello world", "more", "suffix")]
|
||||
const result = findMatchingSuggestion("hello", "suffix", hist)
|
||||
const result = findMatchingSuggestion(scope, "hello", "suffix", hist)
|
||||
expect(result?.matchType).toBe("backward_deletion")
|
||||
expect(result?.text).toBe(" worldmore")
|
||||
})
|
||||
|
||||
it("prefers most recent suggestion (searches from end)", () => {
|
||||
const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")]
|
||||
const result = findMatchingSuggestion("prefix", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "prefix", "", hist)
|
||||
expect(result?.text).toBe("new suggestion")
|
||||
})
|
||||
|
||||
it("returns null when no match found", () => {
|
||||
const hist = [makeSuggestion("different", "no match")]
|
||||
expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull()
|
||||
expect(findMatchingSuggestion(scope, "unrelated", "suffix", hist)).toBeNull()
|
||||
})
|
||||
|
||||
it("does not match empty suggestion text for partial_typing", () => {
|
||||
const hist = [makeSuggestion("prefix", "")]
|
||||
const result = findMatchingSuggestion("prefix more", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "prefix more", "", hist)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("does not reuse suggestions from another scope", () => {
|
||||
const hist = [makeSuggestion("value = ", "first()", "", "vscode-notebook-cell:notebook.ipynb#cell-a")]
|
||||
const result = findMatchingSuggestion("vscode-notebook-cell:notebook.ipynb#cell-b", "value = ", "", hist)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("findCoveringPendingRequest", () => {
|
||||
function request(value: string): PendingRequest {
|
||||
return { scope: value, prefix: "pri", suffix: "", promise: Promise.resolve() }
|
||||
}
|
||||
|
||||
it("reuses forward-typing requests within the same scope", () => {
|
||||
const pending = request(scope)
|
||||
expect(findCoveringPendingRequest(scope, "print", "", [pending])).toBe(pending)
|
||||
})
|
||||
|
||||
it("does not reuse requests from another scope", () => {
|
||||
const pending = request("vscode-notebook-cell:notebook.ipynb#cell-a")
|
||||
expect(findCoveringPendingRequest("vscode-notebook-cell:notebook.ipynb#cell-b", "print", "", [pending])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyFirstLineOnly", () => {
|
||||
@@ -138,21 +163,21 @@ describe("applyFirstLineOnly", () => {
|
||||
|
||||
it("returns empty result unchanged", () => {
|
||||
const hist = [makeSuggestion("p", "")]
|
||||
const result = findMatchingSuggestion("p", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "p", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "p")
|
||||
expect(applied?.text).toBe("")
|
||||
})
|
||||
|
||||
it("truncates to first line when mid-line suggestion", () => {
|
||||
const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")]
|
||||
const result = findMatchingSuggestion("function foo() { return ", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "function foo() { return ", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "function foo() { return ")
|
||||
expect(applied?.text).toBe("x")
|
||||
})
|
||||
|
||||
it("preserves full multi-line suggestion when starting with newline", () => {
|
||||
const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")]
|
||||
const result = findMatchingSuggestion("foo", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "foo", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "foo")
|
||||
expect(applied?.text).toBe("\n const x = 1\n const y = 2")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { notebookModel, selector } from "../../src/services/autocomplete/AutocompleteServiceManager"
|
||||
|
||||
describe("autocomplete document selector", () => {
|
||||
it("registers classic autocomplete for files and notebook cells", () => {
|
||||
expect(selector("classic")).toEqual([{ scheme: "file" }, { scheme: "vscode-notebook-cell" }])
|
||||
})
|
||||
|
||||
it("keeps Next Edit limited to files", () => {
|
||||
expect(selector("next-edit")).toEqual([{ scheme: "file" }])
|
||||
})
|
||||
|
||||
it("uses the matching FIM model for notebook fallback", () => {
|
||||
expect(notebookModel("kilo", "inception/mercury-next-edit").id).toBe("kilo/inception/mercury-edit-2")
|
||||
expect(notebookModel("inception", "mercury-next-edit").id).toBe("inception/mercury-edit-2")
|
||||
expect(notebookModel("kilo", "mistralai/codestral-2508").id).toBe("kilo/mistralai/codestral-2508")
|
||||
})
|
||||
})
|
||||
@@ -6,29 +6,29 @@ import {
|
||||
|
||||
describe("getSuggestionKey", () => {
|
||||
it("combines prefix, suffix, and text with pipe separators", () => {
|
||||
const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" })
|
||||
const key = getSuggestionKey({ scope: "", prefix: "hello ", suffix: "\n}", text: "world" })
|
||||
expect(key).toBe("hello |\n}|world")
|
||||
})
|
||||
|
||||
it("produces unique keys for different suggestions", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" })
|
||||
const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" })
|
||||
const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "b" })
|
||||
const k2 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "x" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("same content produces same key (stable)", () => {
|
||||
const s = { prefix: "const x = ", suffix: ";", text: "42" }
|
||||
const s = { scope: "", prefix: "const x = ", suffix: ";", text: "42" }
|
||||
expect(getSuggestionKey(s)).toBe(getSuggestionKey(s))
|
||||
})
|
||||
|
||||
it("different prefix produces different key", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" })
|
||||
const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" })
|
||||
const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "", text: "t" })
|
||||
const k2 = getSuggestionKey({ scope: "", prefix: "b", suffix: "", text: "t" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("handles empty strings", () => {
|
||||
const key = getSuggestionKey({ prefix: "", suffix: "", text: "" })
|
||||
const key = getSuggestionKey({ scope: "", prefix: "", suffix: "", text: "" })
|
||||
expect(key).toBe("||")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
autocompleteScope,
|
||||
getNotebookContext,
|
||||
notebookUri,
|
||||
supportsNotebook,
|
||||
} from "../../src/services/autocomplete/continuedev/core/autocomplete/notebook"
|
||||
import { accessible } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider"
|
||||
import type { FileIgnoreController } from "../../src/services/autocomplete/shims/FileIgnoreController"
|
||||
|
||||
function uri(scheme: string, path: string, fragment = ""): vscode.Uri {
|
||||
const value = `${scheme}:${path}${fragment ? `#${fragment}` : ""}`
|
||||
return {
|
||||
scheme,
|
||||
fsPath: path,
|
||||
toString: () => value,
|
||||
} as vscode.Uri
|
||||
}
|
||||
|
||||
function document(id: string, text: string, languageId = "python", version = 1): vscode.TextDocument {
|
||||
return {
|
||||
uri: uri("vscode-notebook-cell", "/workspace/example.ipynb", id),
|
||||
fileName: `/workspace/${id}.py`,
|
||||
languageId,
|
||||
version,
|
||||
getText: () => text,
|
||||
} as vscode.TextDocument
|
||||
}
|
||||
|
||||
function notebooks(value: vscode.NotebookDocument[]): void {
|
||||
Object.defineProperty(vscode.workspace, "notebookDocuments", {
|
||||
configurable: true,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
describe("notebook context", () => {
|
||||
beforeEach(() => notebooks([]))
|
||||
|
||||
it("flattens notebook cells and translates the cursor", () => {
|
||||
const markdown = document("markdown", "# Title\nNotes")
|
||||
const code = document("code", "const value = 1\nvalue += 1", "javascript")
|
||||
const current = document("current", "print(value)\nprint('done')")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [
|
||||
{ kind: vscode.NotebookCellKind.Markup, document: markdown },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: code },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const context = getNotebookContext(current, new vscode.Position(1, 5))
|
||||
|
||||
expect(context).toEqual({
|
||||
contents: `"""# Title\nNotes"""\n\nconst value = 1\nvalue += 1\n\nprint(value)\nprint('done')`,
|
||||
filepath: "/workspace/example.ipynb",
|
||||
position: new vscode.Position(7, 5),
|
||||
})
|
||||
})
|
||||
|
||||
it("limits notebook completion to Python code cells", () => {
|
||||
const python = document("python", "value = 1")
|
||||
const javascript = document("javascript", "const value = 1", "javascript")
|
||||
const markdown = document("markdown", "# Heading", "markdown")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: python },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: javascript },
|
||||
{ kind: vscode.NotebookCellKind.Markup, document: markdown },
|
||||
],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(supportsNotebook(python)).toBe(true)
|
||||
expect(supportsNotebook(javascript)).toBe(false)
|
||||
expect(supportsNotebook(markdown)).toBe(false)
|
||||
expect(getNotebookContext(javascript, new vscode.Position(0, 0))).toBeUndefined()
|
||||
expect(getNotebookContext(markdown, new vscode.Position(0, 0))).toBeUndefined()
|
||||
expect(supportsNotebook({ uri: uri("file", "/workspace/file.ts") } as vscode.TextDocument)).toBe(true)
|
||||
})
|
||||
|
||||
it("resolves file and notebook cell URIs", () => {
|
||||
const file = uri("file", "/workspace/file.ts")
|
||||
const cell = document("code", "value = 1")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(notebookUri(file)).toBe(file)
|
||||
expect(notebookUri(cell.uri)).toBe(notebook.uri)
|
||||
expect(notebookUri(uri("untitled", "Untitled-1"))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("scopes autocomplete cache to the cell and sibling versions", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const sibling = document("sibling", "other = 1")
|
||||
const cells = [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: sibling },
|
||||
] as vscode.NotebookCell[]
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => cells,
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const initial = autocompleteScope(current)
|
||||
Object.assign(current, { version: 2 })
|
||||
Object.assign(notebook, { version: 2 })
|
||||
expect(autocompleteScope(current)).toBe(initial)
|
||||
|
||||
Object.assign(sibling, { version: 2 })
|
||||
Object.assign(notebook, { version: 3 })
|
||||
expect(autocompleteScope(current)).not.toBe(initial)
|
||||
expect(autocompleteScope(current)).not.toBe(autocompleteScope(sibling))
|
||||
})
|
||||
|
||||
it("changes autocomplete scope when sibling order changes", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const first = document("first", "first = 1")
|
||||
const second = document("second", "second = 1")
|
||||
const cells = [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: first },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: second },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
] as vscode.NotebookCell[]
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => cells,
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const initial = autocompleteScope(current)
|
||||
cells.splice(0, 2, cells[1]!, cells[0]!)
|
||||
Object.assign(notebook, { version: 2 })
|
||||
|
||||
expect(autocompleteScope(current)).not.toBe(initial)
|
||||
})
|
||||
|
||||
it("reuses notebook resolution within the same notebook version", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const cells = [{ kind: vscode.NotebookCellKind.Code, document: current }] as vscode.NotebookCell[]
|
||||
let calls = 0
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => {
|
||||
calls++
|
||||
return cells
|
||||
},
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(supportsNotebook(current)).toBe(true)
|
||||
expect(notebookUri(current.uri)).toBe(notebook.uri)
|
||||
expect(getNotebookContext(current, new vscode.Position(0, 0))).toBeDefined()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it("validates notebook parent paths regardless of URI scheme", () => {
|
||||
for (const scheme of ["file", "untitled", "memfs"]) {
|
||||
const cell = document(scheme, "value = 1")
|
||||
const notebook = {
|
||||
uri: uri(scheme, `/workspace/${scheme}.ipynb`),
|
||||
getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }],
|
||||
} as vscode.NotebookDocument
|
||||
const paths: string[] = []
|
||||
const controller = {
|
||||
validateAccess: (path: string) => {
|
||||
paths.push(path)
|
||||
return false
|
||||
},
|
||||
} as FileIgnoreController
|
||||
notebooks([notebook])
|
||||
|
||||
expect(accessible(controller, cell)).toBe(false)
|
||||
expect(paths).toEqual([`/workspace/${scheme}.ipynb`])
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user