diff --git a/clients/tabby-agent/src/codeCompletion/contexts.ts b/clients/tabby-agent/src/codeCompletion/contexts.ts index 01255b5f9bfc..751d9b04f554 100644 --- a/clients/tabby-agent/src/codeCompletion/contexts.ts +++ b/clients/tabby-agent/src/codeCompletion/contexts.ts @@ -22,6 +22,7 @@ export type CompletionRequest = { }; declarations?: Declaration[]; relevantSnippetsFromChangedFiles?: CodeSnippet[]; + relevantSnippetsFromOpenedFiles?: CodeSnippet[]; }; export type Declaration = { @@ -68,7 +69,7 @@ export class CompletionContext { declarations?: Declaration[]; relevantSnippetsFromChangedFiles?: CodeSnippet[]; - + snippetsFromOpenedFiles?: CodeSnippet[]; // "default": the cursor is at the end of the line // "fill-in-line": the cursor is not at the end of the line, except auto closed characters // In this case, we assume the completion should be a single line, so multiple lines completion will be dropped. @@ -96,6 +97,7 @@ export class CompletionContext { this.declarations = request.declarations; this.relevantSnippetsFromChangedFiles = request.relevantSnippetsFromChangedFiles; + this.snippetsFromOpenedFiles = request.relevantSnippetsFromOpenedFiles; const lineEnd = isAtLineEndExcludingAutoClosedChar(this.currentLineSuffix); this.mode = lineEnd ? "default" : "fill-in-line"; @@ -203,6 +205,17 @@ export class CompletionContext { }) .sort((a, b) => b.score - a.score); + //FIXME(Sma1lboy): deduplicate in next few PR + const snippetsOpenedFiles = this.snippetsFromOpenedFiles + ?.map((snippet) => { + return { + filepath: snippet.filepath, + body: snippet.text, + score: snippet.score, + }; + }) + .sort((a, b) => b.score - a.score); + // clipboard let clipboard = undefined; if (this.clipboard.length >= config.clipboard.minChars && this.clipboard.length <= config.clipboard.maxChars) { @@ -215,6 +228,7 @@ export class CompletionContext { git_url: gitUrl, declarations, relevant_snippets_from_changed_files: relevantSnippetsFromChangedFiles, + relevant_snippets_from_recently_opened_files: snippetsOpenedFiles, clipboard, }; } diff --git a/clients/tabby-agent/src/codeCompletion/index.ts b/clients/tabby-agent/src/codeCompletion/index.ts index 238738076b4d..3c203ec913ff 100644 --- a/clients/tabby-agent/src/codeCompletion/index.ts +++ b/clients/tabby-agent/src/codeCompletion/index.ts @@ -57,6 +57,7 @@ import { abortSignalFromAnyOf } from "../utils/signal"; import { splitLines, extractNonReservedWordList } from "../utils/string"; import { MutexAbortError, isCanceledError } from "../utils/error"; import { isPositionInRange, intersectionRange } from "../utils/range"; +import { FileTracker } from "../codeSearch/fileTracker"; export class CompletionProvider implements Feature { private readonly logger = getLogger("CompletionProvider"); @@ -80,6 +81,7 @@ export class CompletionProvider implements Feature { private readonly anonymousUsageLogger: AnonymousUsageLogger, private readonly gitContextProvider: GitContextProvider, private readonly recentlyChangedCodeSearch: RecentlyChangedCodeSearch, + private readonly fileTracker: FileTracker, ) {} initialize(connection: Connection, clientCapabilities: ClientCapabilities): ServerCapabilities { @@ -565,7 +567,7 @@ export class CompletionProvider implements Feature { request.declarations = await this.collectDeclarationSnippets(connection, document, position, token); } request.relevantSnippetsFromChangedFiles = await this.collectSnippetsFromRecentlyChangedFiles(document, position); - + request.relevantSnippetsFromOpenedFiles = await this.collectSnippetsFromOpenedFiles(); this.logger.trace("Completed completion context:", { request }); return { request, additionalPrefixLength: additionalContext?.prefix.length }; } @@ -838,6 +840,82 @@ export class CompletionProvider implements Feature { return snippets; } + //get all recently opened files from the file tracker + private async collectSnippetsFromOpenedFiles(): Promise< + { filepath: string; offset: number; text: string; score: number }[] | undefined + > { + const config = this.configurations.getMergedConfig(); + if (!config.completion.prompt.collectSnippetsFromRecentOpenedFiles.enabled) { + return undefined; + } + this.logger.debug("Starting collecting snippets from opened files."); + const recentlyOpenedFiles = this.fileTracker.getAllFilesWithoutActive(); + const codeSnippets: { filepath: string; offset: number; text: string; score: number }[] = []; + const chunkSize = config.completion.prompt.collectSnippetsFromRecentOpenedFiles.maxCharsPerOpenedFiles; + recentlyOpenedFiles.forEach((file) => { + const doc = this.documents.get(file.uri); + if (doc) { + file.lastVisibleRange.forEach((range: Range) => { + this.logger.info( + `Original range: start(${range.start.line},${range.start.character}), end(${range.end.line},${range.end.character})`, + ); + + const startOffset = doc.offsetAt(range.start); + const endOffset = doc.offsetAt(range.end); + const middleOffset = Math.floor((startOffset + endOffset) / 2); + const halfChunkSize = Math.floor(chunkSize / 2); + + const upwardChunkSize = Math.min(halfChunkSize, middleOffset); + const newStartOffset = middleOffset - upwardChunkSize; + + const downwardChunkSize = Math.min(chunkSize - upwardChunkSize, doc.getText().length - middleOffset); + let newEndOffset = middleOffset + downwardChunkSize; + + if (newEndOffset - newStartOffset > chunkSize) { + const excess = newEndOffset - newStartOffset - chunkSize; + newEndOffset -= excess; + } + + let newStart = doc.positionAt(newStartOffset); + let newEnd = doc.positionAt(newEndOffset); + + newStart = { line: newStart.line, character: 0 }; + newEnd = { + line: newEnd.line, + character: doc.getText({ + start: { line: newEnd.line, character: 0 }, + end: { line: newEnd.line + 1, character: 0 }, + }).length, + }; + + this.logger.info( + `New range: start(${newStart.line},${newStart.character}), end(${newEnd.line},${newEnd.character})`, + ); + + const newRange = { start: newStart, end: newEnd }; + let text = doc.getText(newRange); + + if (text.length > chunkSize) { + text = text.substring(0, chunkSize); + } + + this.logger.info(`Text length: ${text.length}`); + this.logger.info(`Upward chunk size: ${upwardChunkSize}, Downward chunk size: ${downwardChunkSize}`); + + codeSnippets.push({ + filepath: file.uri, + offset: newStartOffset, + text: text, + score: file.invisible ? 0.98 : 1, + }); + }); + } + }); + + this.logger.debug("Completed collecting snippets from opened files."); + return codeSnippets; + } + private async submitStats() { const stats = this.completionStats.stats(); if (stats["completion_request"]["count"] > 0) { diff --git a/clients/tabby-agent/src/codeSearch/fileTracker.ts b/clients/tabby-agent/src/codeSearch/fileTracker.ts new file mode 100644 index 000000000000..0d2a5248315c --- /dev/null +++ b/clients/tabby-agent/src/codeSearch/fileTracker.ts @@ -0,0 +1,118 @@ +import { Connection, Range } from "vscode-languageserver"; +import { Feature } from "../feature"; +import { DidChangeActiveEditorNotification, DidChangeActiveEditorParams, ServerCapabilities } from "../protocol"; +import { Configurations } from "../config"; +import { LRUCache } from "lru-cache"; +import { isRangeEqual } from "../utils/range"; + +interface OpenedFile { + uri: string; + //order by range, the left most is the most recent one + lastVisibleRange: Range[]; + invisible: boolean; + isActive: boolean; +} + +export class FileTracker implements Feature { + private fileList = new LRUCache({ + max: this.configurations.getMergedConfig().completion.prompt.collectSnippetsFromRecentOpenedFiles.maxOpenedFiles, + }); + + constructor(private readonly configurations: Configurations) {} + initialize(connection: Connection): ServerCapabilities | Promise { + connection.onNotification(DidChangeActiveEditorNotification.type, (param: DidChangeActiveEditorParams) => { + this.resolveChangedFile(param); + }); + return {}; + } + + resolveChangedFile(param: DidChangeActiveEditorParams) { + const { activeEditor, visibleEditors } = param; + + const visitedPaths = new Set(); + + //get all visible editors + if (visibleEditors) { + visibleEditors.forEach((editor) => { + const visibleFile = this.fileList.get(editor.uri); + if (visibleFile) { + visibleFile.lastVisibleRange = []; + } + }); + + visibleEditors.forEach((editor) => { + let visibleFile = this.fileList.get(editor.uri); + if (!visibleFile) { + visibleFile = { + uri: editor.uri, + lastVisibleRange: [editor.range], + invisible: false, + isActive: false, + }; + this.fileList.set(editor.uri, visibleFile); + } else { + if (visitedPaths.has(visibleFile.uri)) { + const idx = visibleFile.lastVisibleRange.findIndex((range) => isRangeEqual(range, editor.range)); + if (idx === -1) { + visibleFile.lastVisibleRange = [editor.range, ...visibleFile.lastVisibleRange]; + } + visibleFile.invisible = false; + } else { + visibleFile.invisible = false; + visibleFile.lastVisibleRange = [editor.range]; + } + } + visitedPaths.add(visibleFile.uri); + }); + } + + // //get active editor + let file = this.fileList.get(activeEditor.uri); + if (!file) { + file = { + uri: activeEditor.uri, + lastVisibleRange: [activeEditor.range], + invisible: false, + isActive: true, + }; + this.fileList.set(activeEditor.uri, file); + } else { + if (visitedPaths.has(file.uri)) { + const idx = file.lastVisibleRange.findIndex((range) => isRangeEqual(range, activeEditor.range)); + if (idx === -1) { + file.lastVisibleRange = [activeEditor.range, ...file.lastVisibleRange]; + } + } else { + file.lastVisibleRange = [activeEditor.range]; + } + file.invisible = false; + file.isActive = true; + } + visitedPaths.add(file.uri); + + //set invisible flag for all files that are not in the current file list + Array.from(this.fileList.values()) + .filter(this.isOpenedFile) + .forEach((file) => { + if (!visitedPaths.has(file.uri)) { + file.invisible = true; + } + if (file.uri !== activeEditor.uri) { + file.isActive = false; + } + }); + } + private isOpenedFile(file: unknown): file is OpenedFile { + return (file as OpenedFile).uri !== undefined; + } + + /** + * Return All recently opened files by order. [recently opened, ..., oldest] without active file + * @returns return all recently opened files by order + */ + getAllFilesWithoutActive(): OpenedFile[] { + return Array.from(this.fileList.values()) + .filter(this.isOpenedFile) + .filter((f) => !f.isActive); + } +} diff --git a/clients/tabby-agent/src/config/default.ts b/clients/tabby-agent/src/config/default.ts index 885cbd9e5a95..0f17b22ea83e 100644 --- a/clients/tabby-agent/src/config/default.ts +++ b/clients/tabby-agent/src/config/default.ts @@ -38,6 +38,11 @@ export const defaultConfigData: ConfigData = { overlapLines: 1, }, }, + collectSnippetsFromRecentOpenedFiles: { + enabled: true, + maxOpenedFiles: 5, + maxCharsPerOpenedFiles: 500, + }, clipboard: { minChars: 3, maxChars: 2000, diff --git a/clients/tabby-agent/src/config/type.d.ts b/clients/tabby-agent/src/config/type.d.ts index 0526182a9e75..7bf1712df0fa 100644 --- a/clients/tabby-agent/src/config/type.d.ts +++ b/clients/tabby-agent/src/config/type.d.ts @@ -44,6 +44,13 @@ export type ConfigData = { overlapLines: number; }; }; + collectSnippetsFromRecentOpenedFiles: { + enabled: boolean; + //max number of opened files + maxOpenedFiles: number; + //chars size per each opened file + maxCharsPerOpenedFiles: number; + }; clipboard: { minChars: number; maxChars: number; diff --git a/clients/tabby-agent/src/protocol.ts b/clients/tabby-agent/src/protocol.ts index 8a56ba261b02..2bebfa3f61fa 100644 --- a/clients/tabby-agent/src/protocol.ts +++ b/clients/tabby-agent/src/protocol.ts @@ -526,6 +526,26 @@ export type ChatEditResolveCommand = LspCommand & { arguments: [ChatEditResolveParams]; }; +/** + * [Tabby] Did Change Active Editor Notification(➡️) + * + * This method is sent from the client to server when the active editor changed. + * + * + * - method: `tabby/editors/didChangeActiveEditor` + * - params: {@link OpenedFileParams} + * - result: void + */ +export namespace DidChangeActiveEditorNotification { + export const method = "tabby/editors/didChangeActiveEditor"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolNotificationType(method); +} +export type DidChangeActiveEditorParams = { + activeEditor: Location; + visibleEditors: Location[] | undefined; +}; + /** * [Tabby] GenerateCommitMessage Request(↩️) * diff --git a/clients/tabby-agent/src/server.ts b/clients/tabby-agent/src/server.ts index 9153d04fff46..6b0b47520fe7 100644 --- a/clients/tabby-agent/src/server.ts +++ b/clients/tabby-agent/src/server.ts @@ -47,6 +47,7 @@ import { StatusProvider } from "./status"; import { CommandProvider } from "./command"; import { name as serverName, version as serverVersion } from "../package.json"; import "./utils/array"; +import { FileTracker } from "./codeSearch/fileTracker"; export class Server { private readonly logger = getLogger("TabbyLSP"); @@ -66,6 +67,7 @@ export class Server { private readonly gitContextProvider = new GitContextProvider(); private readonly recentlyChangedCodeSearch = new RecentlyChangedCodeSearch(this.configurations, this.documents); + private readonly fileTracker = new FileTracker(this.configurations); private readonly codeLensProvider = new CodeLensProvider(this.documents); private readonly completionProvider = new CompletionProvider( @@ -76,6 +78,7 @@ export class Server { this.anonymousUsageLogger, this.gitContextProvider, this.recentlyChangedCodeSearch, + this.fileTracker, ); private readonly chatFeature = new ChatFeature(this.tabbyApiClient); private readonly chatEditProvider = new ChatEditProvider(this.configurations, this.tabbyApiClient, this.documents); @@ -188,6 +191,7 @@ export class Server { this.commitMessageGenerator, this.statusProvider, this.commandProvider, + this.fileTracker, ].mapAsync((feature: Feature) => { return feature.initialize(this.connection, clientCapabilities, clientProvidedConfig); }); diff --git a/clients/tabby-openapi/lib/tabby.d.ts b/clients/tabby-openapi/lib/tabby.d.ts index 061e86407c63..01e4cb46537e 100644 --- a/clients/tabby-openapi/lib/tabby.d.ts +++ b/clients/tabby-openapi/lib/tabby.d.ts @@ -257,6 +257,8 @@ export interface components { * * Sorted in descending order of [Snippet::score]. */ relevant_snippets_from_changed_files?: components["schemas"]["Snippet"][] | null; + /** @description The relevant code snippets extracted from recently opened files. These snippets are selected from candidates found within code chunks based on the last visited location. Current Active file is excluded from the search candidates. When provided with [Segments::relevant_snippets_from_changed_files], the snippets have already been deduplicated to ensure no duplication with entries in [Segments::relevant_snippets_from_changed_files]. */ + relevant_snippets_from_recently_opened_files?: components["schemas"]["Snippet"][] | null; /** @description Clipboard content when requesting code completion. */ clipboard?: string | null; }; diff --git a/clients/tabby-openapi/openapi.json b/clients/tabby-openapi/openapi.json index ff570484fd09..2382e69deb6e 100644 --- a/clients/tabby-openapi/openapi.json +++ b/clients/tabby-openapi/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"Tabby Server","description":"\n[![tabby stars](https://img.shields.io/github/stars/TabbyML/tabby)](https://github.com/TabbyML/tabby)\n[![Join Slack](https://shields.io/badge/Join-Tabby%20Slack-red?logo=slack)](https://links.tabbyml.com/join-slack)\n\nInstall following IDE / Editor extensions to get started with [Tabby](https://github.com/TabbyML/tabby).\n* [VSCode Extension](https://github.com/TabbyML/tabby/tree/main/clients/vscode) – Install from the [marketplace](https://marketplace.visualstudio.com/items?itemName=TabbyML.vscode-tabby), or [open-vsx.org](https://open-vsx.org/extension/TabbyML/vscode-tabby)\n* [VIM Extension](https://github.com/TabbyML/tabby/tree/main/clients/vim)\n* [IntelliJ Platform Plugin](https://github.com/TabbyML/tabby/tree/main/clients/intellij) – Install from the [marketplace](https://plugins.jetbrains.com/plugin/22379-tabby)\n","contact":{"name":"TabbyML Team"},"license":{"name":"Apache 2.0","url":"https://github.com/TabbyML/tabby/blob/main/LICENSE"},"version":"0.17.0-dev.0"},"servers":[{"url":"/","description":"Server"}],"paths":{"/v1/chat/completions":{"post":{"tags":["v1"],"operationId":"chat_completions","requestBody":{"description":"","content":{"application/json":{"schema":{}}},"required":true},"responses":{"200":{"description":"Success"},"405":{"description":"When chat model is not specified, the endpoint returns 405 Method Not Allowed"},"422":{"description":"When the prompt is malformed, the endpoint returns 422 Unprocessable Entity"}},"security":[{"token":[]}]}},"/v1/completions":{"post":{"tags":["v1"],"operationId":"completion","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletionRequest"}}},"required":true},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletionResponse"}}}},"400":{"description":"Bad Request"}},"security":[{"token":[]}]}},"/v1/events":{"post":{"tags":["v1"],"operationId":"event","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogEventRequest"}}},"required":true},"responses":{"200":{"description":"Success"},"400":{"description":"Bad Request"}},"security":[{"token":[]}]}},"/v1/health":{"get":{"tags":["v1"],"operationId":"health","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthState"}}}}},"security":[{"token":[]}]}},"/v1beta/server_setting":{"get":{"tags":["v1beta"],"operationId":"config","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerSetting"}}}}},"security":[{"token":[]}]}}},"components":{"schemas":{"Choice":{"type":"object","required":["index","text"],"properties":{"index":{"type":"integer","format":"int32","minimum":0},"text":{"type":"string"}}},"CodeSearchDocument":{"type":"object","required":["body","filepath","git_url","language","start_line"],"properties":{"body":{"type":"string"},"filepath":{"type":"string"},"git_url":{"type":"string"},"language":{"type":"string"},"start_line":{"type":"integer","minimum":0}}},"CodeSearchHit":{"type":"object","required":["scores","doc"],"properties":{"scores":{"$ref":"#/components/schemas/CodeSearchScores"},"doc":{"$ref":"#/components/schemas/CodeSearchDocument"}}},"CodeSearchQuery":{"type":"object","required":["git_url","content"],"properties":{"git_url":{"type":"string"},"filepath":{"type":"string","nullable":true},"language":{"type":"string","nullable":true},"content":{"type":"string"}}},"CodeSearchScores":{"type":"object","required":["rrf","bm25","embedding"],"properties":{"rrf":{"type":"number","format":"float","description":"Reciprocal rank fusion score: https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html"},"bm25":{"type":"number","format":"float"},"embedding":{"type":"number","format":"float"}}},"CompletionRequest":{"type":"object","properties":{"language":{"type":"string","description":"Language identifier, full list is maintained at\nhttps://code.visualstudio.com/docs/languages/identifiers","example":"python","nullable":true},"segments":{"allOf":[{"$ref":"#/components/schemas/Segments"}],"nullable":true},"user":{"type":"string","description":"A unique identifier representing your end-user, which can help Tabby to monitor & generating\nreports.","nullable":true},"debug_options":{"allOf":[{"$ref":"#/components/schemas/DebugOptions"}],"nullable":true},"temperature":{"type":"number","format":"float","description":"The temperature parameter for the model, used to tune variance and \"creativity\" of the model output","nullable":true},"seed":{"type":"integer","format":"int64","description":"The seed used for randomly selecting tokens","nullable":true,"minimum":0}},"example":{"language":"python","segments":{"prefix":"def fib(n):\n ","suffix":"\n return fib(n - 1) + fib(n - 2)"}}},"CompletionResponse":{"type":"object","required":["id","choices"],"properties":{"id":{"type":"string"},"choices":{"type":"array","items":{"$ref":"#/components/schemas/Choice"}},"debug_data":{"allOf":[{"$ref":"#/components/schemas/DebugData"}],"nullable":true}},"example":{"choices":[{"index":0,"text":"string"}],"id":"string"}},"DebugData":{"type":"object","properties":{"snippets":{"type":"array","items":{"$ref":"#/components/schemas/Snippet"},"nullable":true},"prompt":{"type":"string","nullable":true}}},"DebugOptions":{"type":"object","properties":{"raw_prompt":{"type":"string","description":"When `raw_prompt` is specified, it will be passed directly to the inference engine for completion. `segments` field in `CompletionRequest` will be ignored.\n\nThis is useful for certain requests that aim to test the tabby's e2e quality.","nullable":true},"return_snippets":{"type":"boolean","description":"When true, returns `snippets` in `debug_data`."},"return_prompt":{"type":"boolean","description":"When true, returns `prompt` in `debug_data`."},"disable_retrieval_augmented_code_completion":{"type":"boolean","description":"When true, disable retrieval augmented code completion."}}},"Declaration":{"type":"object","description":"A snippet of declaration code that is relevant to the current completion request.","required":["filepath","body"],"properties":{"filepath":{"type":"string","description":"Filepath of the file where the snippet is from.\n- When the file belongs to the same workspace as the current file,\nthis is a relative filepath, use the same rule as [Segments::filepath].\n- When the file located outside the workspace, such as in a dependency package,\nthis is a file URI with an absolute filepath."},"body":{"type":"string","description":"Body of the snippet."}}},"DocSearchDocument":{"type":"object","required":["title","link","snippet"],"properties":{"title":{"type":"string"},"link":{"type":"string"},"snippet":{"type":"string"}}},"DocSearchHit":{"type":"object","required":["score","doc"],"properties":{"score":{"type":"number","format":"float"},"doc":{"$ref":"#/components/schemas/DocSearchDocument"}}},"HealthState":{"type":"object","required":["device","arch","cpu_info","cpu_count","cuda_devices","version"],"properties":{"model":{"type":"string","nullable":true},"chat_model":{"type":"string","nullable":true},"chat_device":{"type":"string","nullable":true},"device":{"type":"string"},"arch":{"type":"string"},"cpu_info":{"type":"string"},"cpu_count":{"type":"integer","minimum":0},"cuda_devices":{"type":"array","items":{"type":"string"}},"version":{"$ref":"#/components/schemas/Version"},"webserver":{"type":"boolean","nullable":true}}},"LogEventRequest":{"type":"object","required":["type","completion_id","choice_index"],"properties":{"type":{"type":"string","description":"Event type, should be `view`, `select` or `dismiss`.","example":"view"},"completion_id":{"type":"string"},"choice_index":{"type":"integer","format":"int32","minimum":0},"view_id":{"type":"string","nullable":true},"elapsed":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"Segments":{"type":"object","required":["prefix"],"properties":{"prefix":{"type":"string","description":"Content that appears before the cursor in the editor window."},"suffix":{"type":"string","description":"Content that appears after the cursor in the editor window.","nullable":true},"filepath":{"type":"string","description":"The relative path of the file that is being edited.\n- When [Segments::git_url] is set, this is the path of the file in the git repository.\n- When [Segments::git_url] is empty, this is the path of the file in the workspace.","nullable":true},"git_url":{"type":"string","description":"The remote URL of the current git repository.\nLeave this empty if the file is not in a git repository,\nor the git repository does not have a remote URL.","nullable":true},"declarations":{"type":"array","items":{"$ref":"#/components/schemas/Declaration"},"description":"The relevant declaration code snippets provided by the editor's LSP,\ncontain declarations of symbols extracted from [Segments::prefix].","nullable":true},"relevant_snippets_from_changed_files":{"type":"array","items":{"$ref":"#/components/schemas/Snippet"},"description":"The relevant code snippets extracted from recently edited files.\nThese snippets are selected from candidates found within code chunks\nbased on the edited location.\nThe current editing file is excluded from the search candidates.\n\nWhen provided alongside [Segments::declarations], the snippets have\nalready been deduplicated to ensure no duplication with entries\nin [Segments::declarations].\n\nSorted in descending order of [Snippet::score].","nullable":true},"clipboard":{"type":"string","description":"Clipboard content when requesting code completion.","nullable":true}}},"ServerSetting":{"type":"object","required":["disable_client_side_telemetry"],"properties":{"disable_client_side_telemetry":{"type":"boolean"}}},"Snippet":{"type":"object","required":["filepath","body","score"],"properties":{"filepath":{"type":"string"},"body":{"type":"string"},"score":{"type":"number","format":"float"}}},"Version":{"type":"object","required":["build_date","build_timestamp","git_sha","git_describe"],"properties":{"build_date":{"type":"string"},"build_timestamp":{"type":"string"},"git_sha":{"type":"string"},"git_describe":{"type":"string"}}}},"securitySchemes":{"token":{"type":"http","scheme":"bearer","bearerFormat":"token"}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"title":"Tabby Server","description":"\n[![tabby stars](https://img.shields.io/github/stars/TabbyML/tabby)](https://github.com/TabbyML/tabby)\n[![Join Slack](https://shields.io/badge/Join-Tabby%20Slack-red?logo=slack)](https://links.tabbyml.com/join-slack)\n\nInstall following IDE / Editor extensions to get started with [Tabby](https://github.com/TabbyML/tabby).\n* [VSCode Extension](https://github.com/TabbyML/tabby/tree/main/clients/vscode) – Install from the [marketplace](https://marketplace.visualstudio.com/items?itemName=TabbyML.vscode-tabby), or [open-vsx.org](https://open-vsx.org/extension/TabbyML/vscode-tabby)\n* [VIM Extension](https://github.com/TabbyML/tabby/tree/main/clients/vim)\n* [IntelliJ Platform Plugin](https://github.com/TabbyML/tabby/tree/main/clients/intellij) – Install from the [marketplace](https://plugins.jetbrains.com/plugin/22379-tabby)\n","contact":{"name":"TabbyML Team"},"license":{"name":"Apache 2.0","url":"https://github.com/TabbyML/tabby/blob/main/LICENSE"},"version":"0.17.0-dev.0"},"servers":[{"url":"/","description":"Server"}],"paths":{"/v1/chat/completions":{"post":{"tags":["v1"],"operationId":"chat_completions","requestBody":{"description":"","content":{"application/json":{"schema":{}}},"required":true},"responses":{"200":{"description":"Success"},"405":{"description":"When chat model is not specified, the endpoint returns 405 Method Not Allowed"},"422":{"description":"When the prompt is malformed, the endpoint returns 422 Unprocessable Entity"}},"security":[{"token":[]}]}},"/v1/completions":{"post":{"tags":["v1"],"operationId":"completion","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletionRequest"}}},"required":true},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletionResponse"}}}},"400":{"description":"Bad Request"}},"security":[{"token":[]}]}},"/v1/events":{"post":{"tags":["v1"],"operationId":"event","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogEventRequest"}}},"required":true},"responses":{"200":{"description":"Success"},"400":{"description":"Bad Request"}},"security":[{"token":[]}]}},"/v1/health":{"get":{"tags":["v1"],"operationId":"health","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthState"}}}}},"security":[{"token":[]}]}},"/v1beta/server_setting":{"get":{"tags":["v1beta"],"operationId":"config","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerSetting"}}}}},"security":[{"token":[]}]}}},"components":{"schemas":{"Choice":{"type":"object","required":["index","text"],"properties":{"index":{"type":"integer","format":"int32","minimum":0},"text":{"type":"string"}}},"CodeSearchDocument":{"type":"object","required":["body","filepath","git_url","language","start_line"],"properties":{"body":{"type":"string"},"filepath":{"type":"string"},"git_url":{"type":"string"},"language":{"type":"string"},"start_line":{"type":"integer","minimum":0}}},"CodeSearchHit":{"type":"object","required":["scores","doc"],"properties":{"scores":{"$ref":"#/components/schemas/CodeSearchScores"},"doc":{"$ref":"#/components/schemas/CodeSearchDocument"}}},"CodeSearchQuery":{"type":"object","required":["git_url","content"],"properties":{"git_url":{"type":"string"},"filepath":{"type":"string","nullable":true},"language":{"type":"string","nullable":true},"content":{"type":"string"}}},"CodeSearchScores":{"type":"object","required":["rrf","bm25","embedding"],"properties":{"rrf":{"type":"number","format":"float","description":"Reciprocal rank fusion score: https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html"},"bm25":{"type":"number","format":"float"},"embedding":{"type":"number","format":"float"}}},"CompletionRequest":{"type":"object","properties":{"language":{"type":"string","description":"Language identifier, full list is maintained at\nhttps://code.visualstudio.com/docs/languages/identifiers","example":"python","nullable":true},"segments":{"allOf":[{"$ref":"#/components/schemas/Segments"}],"nullable":true},"user":{"type":"string","description":"A unique identifier representing your end-user, which can help Tabby to monitor & generating\nreports.","nullable":true},"debug_options":{"allOf":[{"$ref":"#/components/schemas/DebugOptions"}],"nullable":true},"temperature":{"type":"number","format":"float","description":"The temperature parameter for the model, used to tune variance and \"creativity\" of the model output","nullable":true},"seed":{"type":"integer","format":"int64","description":"The seed used for randomly selecting tokens","nullable":true,"minimum":0}},"example":{"language":"python","segments":{"prefix":"def fib(n):\n ","suffix":"\n return fib(n - 1) + fib(n - 2)"}}},"CompletionResponse":{"type":"object","required":["id","choices"],"properties":{"id":{"type":"string"},"choices":{"type":"array","items":{"$ref":"#/components/schemas/Choice"}},"debug_data":{"allOf":[{"$ref":"#/components/schemas/DebugData"}],"nullable":true}},"example":{"choices":[{"index":0,"text":"string"}],"id":"string"}},"DebugData":{"type":"object","properties":{"snippets":{"type":"array","items":{"$ref":"#/components/schemas/Snippet"},"nullable":true},"prompt":{"type":"string","nullable":true}}},"DebugOptions":{"type":"object","properties":{"raw_prompt":{"type":"string","description":"When `raw_prompt` is specified, it will be passed directly to the inference engine for completion. `segments` field in `CompletionRequest` will be ignored.\n\nThis is useful for certain requests that aim to test the tabby's e2e quality.","nullable":true},"return_snippets":{"type":"boolean","description":"When true, returns `snippets` in `debug_data`."},"return_prompt":{"type":"boolean","description":"When true, returns `prompt` in `debug_data`."},"disable_retrieval_augmented_code_completion":{"type":"boolean","description":"When true, disable retrieval augmented code completion."}}},"Declaration":{"type":"object","description":"A snippet of declaration code that is relevant to the current completion request.","required":["filepath","body"],"properties":{"filepath":{"type":"string","description":"Filepath of the file where the snippet is from.\n- When the file belongs to the same workspace as the current file,\nthis is a relative filepath, use the same rule as [Segments::filepath].\n- When the file located outside the workspace, such as in a dependency package,\nthis is a file URI with an absolute filepath."},"body":{"type":"string","description":"Body of the snippet."}}},"DocSearchDocument":{"type":"object","required":["title","link","snippet"],"properties":{"title":{"type":"string"},"link":{"type":"string"},"snippet":{"type":"string"}}},"DocSearchHit":{"type":"object","required":["score","doc"],"properties":{"score":{"type":"number","format":"float"},"doc":{"$ref":"#/components/schemas/DocSearchDocument"}}},"HealthState":{"type":"object","required":["device","arch","cpu_info","cpu_count","cuda_devices","version"],"properties":{"model":{"type":"string","nullable":true},"chat_model":{"type":"string","nullable":true},"chat_device":{"type":"string","nullable":true},"device":{"type":"string"},"arch":{"type":"string"},"cpu_info":{"type":"string"},"cpu_count":{"type":"integer","minimum":0},"cuda_devices":{"type":"array","items":{"type":"string"}},"version":{"$ref":"#/components/schemas/Version"},"webserver":{"type":"boolean","nullable":true}}},"LogEventRequest":{"type":"object","required":["type","completion_id","choice_index"],"properties":{"type":{"type":"string","description":"Event type, should be `view`, `select` or `dismiss`.","example":"view"},"completion_id":{"type":"string"},"choice_index":{"type":"integer","format":"int32","minimum":0},"view_id":{"type":"string","nullable":true},"elapsed":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"Segments":{"type":"object","required":["prefix"],"properties":{"prefix":{"type":"string","description":"Content that appears before the cursor in the editor window."},"suffix":{"type":"string","description":"Content that appears after the cursor in the editor window.","nullable":true},"filepath":{"type":"string","description":"The relative path of the file that is being edited.\n- When [Segments::git_url] is set, this is the path of the file in the git repository.\n- When [Segments::git_url] is empty, this is the path of the file in the workspace.","nullable":true},"git_url":{"type":"string","description":"The remote URL of the current git repository.\nLeave this empty if the file is not in a git repository,\nor the git repository does not have a remote URL.","nullable":true},"declarations":{"type":"array","items":{"$ref":"#/components/schemas/Declaration"},"description":"The relevant declaration code snippets provided by the editor's LSP,\ncontain declarations of symbols extracted from [Segments::prefix].","nullable":true},"relevant_snippets_from_changed_files":{"type":"array","items":{"$ref":"#/components/schemas/Snippet"},"description":"The relevant code snippets extracted from recently edited files.\nThese snippets are selected from candidates found within code chunks\nbased on the edited location.\nThe current editing file is excluded from the search candidates.\n\nWhen provided alongside [Segments::declarations], the snippets have\nalready been deduplicated to ensure no duplication with entries\nin [Segments::declarations].\n\nSorted in descending order of [Snippet::score].","nullable":true},"relevant_snippets_from_recently_opened_files": {"type": "array","items": {"$ref": "#/components/schemas/Snippet"},"description": "The relevant code snippets extracted from recently opened files. These snippets are selected from candidates found within code chunks based on the last visited location. Current Active file is excluded from the search candidates. When provided with [Segments::relevant_snippets_from_changed_files], the snippets have already been deduplicated to ensure no duplication with entries in [Segments::relevant_snippets_from_changed_files].","nullable": true},"clipboard":{"type":"string","description":"Clipboard content when requesting code completion.","nullable":true}}},"ServerSetting":{"type":"object","required":["disable_client_side_telemetry"],"properties":{"disable_client_side_telemetry":{"type":"boolean"}}},"Snippet":{"type":"object","required":["filepath","body","score"],"properties":{"filepath":{"type":"string"},"body":{"type":"string"},"score":{"type":"number","format":"float"}}},"Version":{"type":"object","required":["build_date","build_timestamp","git_sha","git_describe"],"properties":{"build_date":{"type":"string"},"build_timestamp":{"type":"string"},"git_sha":{"type":"string"},"git_describe":{"type":"string"}}}},"securitySchemes":{"token":{"type":"http","scheme":"bearer","bearerFormat":"token"}}}} \ No newline at end of file diff --git a/clients/vscode/src/InlineCompletionProvider.ts b/clients/vscode/src/InlineCompletionProvider.ts index eefd6bce1e6a..9b2bd7ecf259 100644 --- a/clients/vscode/src/InlineCompletionProvider.ts +++ b/clients/vscode/src/InlineCompletionProvider.ts @@ -96,6 +96,7 @@ export class InlineCompletionProvider extends EventEmitter implements InlineComp }; let request: Promise | undefined = undefined; try { + this.client.fileTrack.addingChangeEditor(window.activeTextEditor); request = this.client.languageClient.sendRequest(InlineCompletionRequest.method, params, token); this.ongoing = request; this.emit("didChangeLoading", true); diff --git a/clients/vscode/src/lsp/Client.ts b/clients/vscode/src/lsp/Client.ts index 6e44e6de54b9..d22f60b7912a 100644 --- a/clients/vscode/src/lsp/Client.ts +++ b/clients/vscode/src/lsp/Client.ts @@ -17,12 +17,14 @@ import { Config } from "../Config"; import { InlineCompletionProvider } from "../InlineCompletionProvider"; import { GitProvider } from "../git/GitProvider"; import { getLogger } from "../logger"; +import { FileTrackerFeature } from "./FileTrackFeature"; export class Client { private readonly logger = getLogger(""); readonly agent: AgentFeature; readonly chat: ChatFeature; readonly telemetry: TelemetryFeature; + readonly fileTrack: FileTrackerFeature; constructor( private readonly context: ExtensionContext, readonly languageClient: BaseLanguageClient, @@ -30,9 +32,11 @@ export class Client { this.agent = new AgentFeature(this.languageClient); this.chat = new ChatFeature(this.languageClient); this.telemetry = new TelemetryFeature(this.languageClient); + this.fileTrack = new FileTrackerFeature(this, this.context); this.languageClient.registerFeature(this.agent); this.languageClient.registerFeature(this.chat); this.languageClient.registerFeature(this.telemetry); + this.languageClient.registerFeature(this.fileTrack); this.languageClient.registerFeature(new DataStoreFeature(this.context, this.languageClient)); this.languageClient.registerFeature(new EditorOptionsFeature(this.languageClient)); this.languageClient.registerFeature(new LanguageSupportFeature(this.languageClient)); diff --git a/clients/vscode/src/lsp/FileTrackFeature.ts b/clients/vscode/src/lsp/FileTrackFeature.ts new file mode 100644 index 000000000000..a376d1c446d2 --- /dev/null +++ b/clients/vscode/src/lsp/FileTrackFeature.ts @@ -0,0 +1,59 @@ +import { DidChangeActiveEditorNotification, DidChangeActiveEditorParams } from "tabby-agent"; +import { Client } from "./Client"; +import { ExtensionContext, TextEditor, window } from "vscode"; +import { + DocumentSelector, + FeatureState, + InitializeParams, + ServerCapabilities, + StaticFeature, +} from "vscode-languageclient"; +import EventEmitter from "events"; +import { collectVisibleEditors } from "../windowUtils"; + +export class FileTrackerFeature extends EventEmitter implements StaticFeature { + constructor( + private readonly client: Client, + private readonly context: ExtensionContext, + ) { + super(); + } + fillInitializeParams?: ((params: InitializeParams) => void) | undefined; + fillClientCapabilities(): void { + //nothing + } + preInitialize?: + | ((capabilities: ServerCapabilities, documentSelector: DocumentSelector | undefined) => void) + | undefined; + initialize(): void { + this.context.subscriptions.push( + //when active text editor changes + window.onDidChangeActiveTextEditor(async (editor) => { + await this.addingChangeEditor(editor); + }), + ); + } + getState(): FeatureState { + throw new Error("Method not implemented."); + } + clear(): void { + throw new Error("Method not implemented."); + } + + async addingChangeEditor(editor: TextEditor | undefined) { + if (editor && editor.visibleRanges[0] && editor.document.fileName.startsWith("/")) { + const editorRange = editor.visibleRanges[0]; + const params: DidChangeActiveEditorParams = { + activeEditor: { + uri: editor.document.uri.toString(), + range: { + start: { line: editorRange.start.line, character: editorRange.start.character }, + end: { line: editorRange.end.line, character: editorRange.end.character }, + }, + }, + visibleEditors: collectVisibleEditors(true, editor), + }; + await this.client.languageClient.sendNotification(DidChangeActiveEditorNotification.method, params); + } + } +} diff --git a/clients/vscode/src/windowUtils.ts b/clients/vscode/src/windowUtils.ts new file mode 100644 index 000000000000..72721fc30706 --- /dev/null +++ b/clients/vscode/src/windowUtils.ts @@ -0,0 +1,67 @@ +import { TextEditor, window } from "vscode"; +import { Location } from "vscode-languageclient"; + +export function collectVisibleEditors(exceptActiveEditor = false, activeEditor?: TextEditor): Location[] { + let editors = window.visibleTextEditors + .filter((e) => e.document.fileName.startsWith("/")) + .map((editor) => { + if (!editor.visibleRanges[0]) { + return null; + } + return { + uri: editor.document.uri.toString(), + range: { + start: { + line: editor.visibleRanges[0].start.line, + character: editor.visibleRanges[0].start.character, + }, + end: { + line: editor.visibleRanges[0].end.line, + character: editor.visibleRanges[0].end.character, + }, + }, + } as Location; + }) + .filter((e): e is Location => e !== null) + .sort((a, b) => + a.uri === window.activeTextEditor?.document.uri.toString() + ? -1 + : b.uri === window.activeTextEditor?.document.uri.toString() + ? 1 + : 0, + ); + if (exceptActiveEditor) { + if (activeEditor && activeEditor.visibleRanges[0]) { + const range = activeEditor.visibleRanges[0]; + editors = editors.filter( + (e) => + e.uri !== activeEditor.document.uri.toString() || + e.range.start.line !== range.start.line || + e.range.start.character !== range.start.character || + e.range.end.line !== range.end.line || + e.range.end.character !== range.end.character, + ); + } + } + return editors; +} +export function collectActiveEditor(): Location | undefined { + const activeEditor = window.activeTextEditor; + //only return TextDocument editor + if (!activeEditor || !activeEditor.visibleRanges[0] || !activeEditor.document.fileName.startsWith("/")) { + return undefined; + } + return { + uri: activeEditor.document.uri.toString(), + range: { + start: { + line: activeEditor.visibleRanges[0].start.line, + character: activeEditor.visibleRanges[0].start.character, + }, + end: { + line: activeEditor.visibleRanges[0].end.line, + character: activeEditor.visibleRanges[0].end.character, + }, + }, + }; +} diff --git a/crates/tabby/src/services/completion.rs b/crates/tabby/src/services/completion.rs index b489dd159783..c713bb6ae536 100644 --- a/crates/tabby/src/services/completion.rs +++ b/crates/tabby/src/services/completion.rs @@ -136,6 +136,16 @@ pub struct Segments { /// Sorted in descending order of [Snippet::score]. relevant_snippets_from_changed_files: Option>, + /// The relevant code snippets extracted from recently opened files. + /// These snippets are selected from candidates found within code chunks + /// based on the last visited location. + /// + /// Current Active file is excluded from the search candidates. + /// When provided with [Segments::relevant_snippets_from_changed_files], the snippets have + /// already been deduplicated to ensure no duplication with entries + /// in [Segments::relevant_snippets_from_changed_files]. + relevant_snippets_from_recently_opened_files: Option>, + /// Clipboard content when requesting code completion. clipboard: Option, } @@ -456,6 +466,7 @@ mod tests { git_url: None, declarations: None, relevant_snippets_from_changed_files: None, + relevant_snippets_from_recently_opened_files: None, clipboard: None, }; let request = CompletionRequest { diff --git a/crates/tabby/src/services/completion/completion_prompt.rs b/crates/tabby/src/services/completion/completion_prompt.rs index 8bcbd8f3d0bb..622f9f76d9e0 100644 --- a/crates/tabby/src/services/completion/completion_prompt.rs +++ b/crates/tabby/src/services/completion/completion_prompt.rs @@ -155,7 +155,6 @@ fn extract_snippets_from_segments( if count_characters + declaration.body.len() > max_snippets_chars { break; } - count_characters += declaration.body.len(); ret.push(Snippet { filepath: declaration.filepath.clone(), @@ -181,6 +180,22 @@ fn extract_snippets_from_segments( } } + // then comes to the snippets from recently opened files. + if let Some(relevant_snippets) = &segments.relevant_snippets_from_recently_opened_files { + for snippet in relevant_snippets { + if count_characters + snippet.body.len() > max_snippets_chars { + break; + } + + count_characters += snippet.body.len(); + ret.push(Snippet { + filepath: snippet.filepath.clone(), + body: snippet.body.clone(), + score: 1.0, + }); + } + } + if ret.is_empty() { None } else { @@ -276,6 +291,7 @@ mod tests { git_url: None, declarations: None, relevant_snippets_from_changed_files: None, + relevant_snippets_from_recently_opened_files: None, clipboard: None, } } @@ -496,6 +512,7 @@ def this_is_prefix():\n"; git_url: None, declarations: None, relevant_snippets_from_changed_files: None, + relevant_snippets_from_recently_opened_files: None, clipboard: None, }; @@ -517,12 +534,17 @@ def this_is_prefix():\n"; body: "res_1 = invoke_function_1(n)".to_owned(), score: 1.0, }]), + relevant_snippets_from_recently_opened_files: Some(vec![Snippet { + filepath: "b1.py".to_owned(), + body: "res_1 = invoke_function_1(n)".to_owned(), + score: 1.0, + }]), clipboard: None, }; assert!( extract_snippets_from_segments(max_snippets_chars, &segments) - .is_some_and(|x| x.1.len() == 2) + .is_some_and(|x| x.1.len() == 3) ); } }