diff --git a/clients/tabby-agent/package.json b/clients/tabby-agent/package.json index ae5cd7df7b0d..a8051364fac8 100644 --- a/clients/tabby-agent/package.json +++ b/clients/tabby-agent/package.json @@ -17,28 +17,23 @@ "node": ">=18" }, "files": [ - "./dist/cli.js", - "./dist/index.js", - "./dist/index.mjs", - "./dist/index.d.ts", - "./dist/wasm/**", - "./dist/win-ca/**" + "./dist/**" ], "bin": { - "tabby-agent": "./dist/cli.js" + "tabby-agent": "./dist/node/index.js" }, - "main": "./dist/index.js", - "browser": "./dist/index.mjs", - "types": "./dist/index.d.ts", + "main": "./dist/protocol.js", + "types": "./dist/protocol.d.ts", "scripts": { + "build": "tsc --noEmit && tsup --minify --treeshake smallest", + "watch": "tsc-watch --noEmit --onSuccess \"tsup\"", "openapi-codegen": "openapi-typescript ./openapi/tabby.json -o ./src/types/tabbyApi.d.ts", - "dev": "tsup --watch --no-minify --no-treeshake", - "build": "tsc --noEmit && tsup", "test": "mocha", "lint": "eslint --ext .ts ./src && prettier --check .", "lint:fix": "eslint --fix --ext .ts ./src && prettier --write ." }, "devDependencies": { + "@orama/orama": "^2.0.18", "@types/chai": "^4.3.5", "@types/dedent": "^0.7.2", "@types/deep-equal": "^1.0.4", @@ -54,41 +49,39 @@ "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", "chai": "^4.3.7", + "chokidar": "^3.5.3", "dedent": "^0.7.0", + "deep-equal": "^2.2.1", + "deepmerge-ts": "^5.1.0", + "dot-prop": "^8.0.2", "esbuild-plugin-copy": "^2.1.1", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.55.0", "eslint-config-prettier": "^9.0.0", - "glob": "^7.2.0", - "mocha": "^10.2.0", - "openapi-typescript": "^6.6.1", - "prettier": "^3.0.0", - "ts-node": "^10.9.1", - "tsup": "^7.1.0", - "typescript": "^5.3.2" - }, - "dependencies": { - "axios": "^1.7.2", - "chokidar": "^3.5.3", - "deep-equal": "^2.2.1", - "deepmerge-ts": "^5.1.0", - "dot-prop": "^8.0.2", "eventsource-parser": "^1.1.2", "fast-levenshtein": "^3.0.0", "file-stream-rotator": "^1.0.0", - "form-data": "^4.0.0", "fs-extra": "^11.1.1", + "glob": "^7.2.0", "jwt-decode": "^3.1.2", "lru-cache": "^9.1.1", "mac-ca": "^2.0.3", + "mocha": "^10.2.0", "object-hash": "^3.0.0", "openapi-fetch": "^0.7.6", + "openapi-typescript": "^6.6.1", "pino": "^8.14.1", + "prettier": "^3.0.0", "semver": "^7.6.0", "stats-logscale": "^1.0.9", "toml": "^3.0.0", + "ts-node": "^10.9.1", + "tsc-watch": "^6.2.0", + "tsup": "^8.0.2", + "typescript": "^5.3.2", "uuid": "^9.0.0", "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "web-tree-sitter": "^0.20.8", "win-ca": "^3.5.1" diff --git a/clients/tabby-agent/src/Auth.ts b/clients/tabby-agent/src/Auth.ts index 602e7f696273..605496bf656e 100644 --- a/clients/tabby-agent/src/Auth.ts +++ b/clients/tabby-agent/src/Auth.ts @@ -178,7 +178,7 @@ export class Auth extends EventEmitter { clearInterval(timer); resolve(true); } catch (error) { - if (error! instanceof HttpError && [400, 401, 403, 405].includes(error.status)) { + if (!(error instanceof HttpError && [400, 401, 403, 405].includes(error.status))) { // unknown error but still keep polling this.logger.error("Failed due to unknown error when polling auth token", error); } @@ -211,7 +211,7 @@ export class Auth extends EventEmitter { payload: decodeJwt(refreshedJwt.data.jwt), }; } catch (error) { - if (error! instanceof HttpError && [400, 401, 403, 405].includes(error.status)) { + if (!(error instanceof HttpError && [400, 401, 403, 405].includes(error.status))) { // unknown error, retry a few times this.logger.error("Failed due to unknown error when refreshing auth token.", error); if (retry < options.maxTry) { diff --git a/clients/tabby-agent/src/CompletionContext.ts b/clients/tabby-agent/src/CompletionContext.ts index e6b61f42c142..404eb0e76d7b 100644 --- a/clients/tabby-agent/src/CompletionContext.ts +++ b/clients/tabby-agent/src/CompletionContext.ts @@ -26,8 +26,8 @@ export type CompletionRequest = { export type Declaration = { filepath: string; - offset: number; text: string; + offset?: number; }; export type CodeSnippet = { @@ -183,6 +183,7 @@ export class CompletionContext { !this.declarations?.find((declaration) => { return ( declaration.filepath === snippet.filepath && + declaration.offset && // Is range overlapping Math.max(declaration.offset, snippet.offset) <= Math.min(declaration.offset + declaration.text.length, snippet.offset + snippet.text.length) diff --git a/clients/tabby-agent/src/LspServer.ts b/clients/tabby-agent/src/LspServer.ts deleted file mode 100644 index 0b03c2337416..000000000000 --- a/clients/tabby-agent/src/LspServer.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - createConnection, - TextDocuments, - TextDocumentSyncKind, - InitializeParams, - InitializeResult, - ShowMessageParams, - MessageType, - CompletionParams, - CompletionList, - CompletionItem, - CompletionItemKind, - TextDocumentPositionParams, -} from "vscode-languageserver/node"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { name as agentName, version as agentVersion } from "../package.json"; -import { Agent, StatusChangedEvent, CompletionRequest, CompletionResponse } from "./Agent"; -import { getLogger } from "./logger"; -import { splitLines, isCanceledError } from "./utils"; - -export class LspServer { - private readonly connection = createConnection(); - private readonly documents = new TextDocuments(TextDocument); - - private readonly logger = getLogger("LSP"); - - private agent?: Agent; - - constructor() { - this.connection.onInitialize(async (params) => { - return await this.initialize(params); - }); - this.connection.onShutdown(async () => { - return await this.shutdown(); - }); - this.connection.onExit(async () => { - return this.exit(); - }); - this.connection.onCompletion(async (params) => { - return await this.completion(params); - }); - } - - bind(agent: Agent): void { - this.agent = agent; - - this.agent.on("statusChanged", (event: StatusChangedEvent) => { - if (event.status === "disconnected" || event.status === "unauthorized") { - this.showMessage({ - type: MessageType.Warning, - message: `Tabby agent status: ${event.status}`, - }); - } - }); - } - - listen() { - this.documents.listen(this.connection); - this.connection.listen(); - } - - // LSP interface methods - - async initialize(params: InitializeParams): Promise { - this.logger.debug("[-->] Initialize Request"); - this.logger.trace("Initialize params:", params); - if (!this.agent) { - throw new Error(`Agent not bound.\n`); - } - - const { clientInfo, capabilities } = params; - if (capabilities.textDocument?.inlineCompletion) { - // TODO: use inlineCompletion instead of completion - } - - await this.agent.initialize({ - clientProperties: { - session: { - client: `${clientInfo?.name} ${clientInfo?.version ?? ""}`, - ide: { - name: clientInfo?.name, - version: clientInfo?.version, - }, - tabby_plugin: { - name: `${agentName} (LSP)`, - version: agentVersion, - }, - }, - }, - }); - - const result: InitializeResult = { - capabilities: { - textDocumentSync: { - openClose: true, - change: TextDocumentSyncKind.Incremental, - }, - completionProvider: {}, - // inlineCompletionProvider: {}, - }, - serverInfo: { - name: agentName, - version: agentVersion, - }, - }; - this.logger.debug("[<--] Initialize Response"); - this.logger.trace("Initialize result:", result); - return result; - } - - async shutdown() { - this.logger.debug("[-->] shutdown"); - if (!this.agent) { - throw new Error(`Agent not bound.\n`); - } - - await this.agent.finalize(); - this.logger.debug("[<--] shutdown"); - } - - exit() { - this.logger.debug("[-->] exit"); - return process.exit(0); - } - - async showMessage(params: ShowMessageParams) { - this.logger.debug("[<--] window/showMessage"); - this.logger.trace("ShowMessage params:", params); - await this.connection.sendNotification("window/showMessage", params); - } - - async completion(params: CompletionParams): Promise { - this.logger.debug("[-->] textDocument/completion"); - this.logger.trace("Completion params:", params); - if (!this.agent) { - throw new Error(`Agent not bound.\n`); - } - - let completionList: CompletionList = { - isIncomplete: true, - items: [], - }; - try { - const request = this.buildCompletionRequest(params); - const response = await this.agent.provideCompletions(request); - completionList = this.toCompletionList(response, params); - } catch (error) { - if (isCanceledError(error)) { - this.logger.debug("Completion request canceled."); - } else { - this.logger.error("Completion request failed.", error); - } - } - - this.logger.debug("[<--] textDocument/completion"); - this.logger.trace("Completion result:", completionList); - return completionList; - } - - private buildCompletionRequest( - documentPosition: TextDocumentPositionParams, - manually: boolean = false, - ): CompletionRequest { - const { textDocument, position } = documentPosition; - const document = this.documents.get(textDocument.uri)!; - - const request: CompletionRequest = { - filepath: document.uri, - language: document.languageId, - text: document.getText(), - position: document.offsetAt(position), - manually, - }; - return request; - } - - private toCompletionList(response: CompletionResponse, documentPosition: TextDocumentPositionParams): CompletionList { - const { textDocument, position } = documentPosition; - const document = this.documents.get(textDocument.uri)!; - - // Get word prefix if cursor is at end of a word - const linePrefix = document.getText({ - start: { line: position.line, character: 0 }, - end: position, - }); - const wordPrefix = linePrefix.match(/(\w+)$/)?.[0] ?? ""; - - return { - isIncomplete: response.isIncomplete, - items: response.items.map((item): CompletionItem => { - const insertionText = item.insertText.slice(document.offsetAt(position) - item.range.start); - - const lines = splitLines(insertionText); - const firstLine = lines[0] || ""; - const secondLine = lines[1] || ""; - return { - label: wordPrefix + firstLine, - labelDetails: { - detail: secondLine, - description: "Tabby", - }, - kind: CompletionItemKind.Text, - documentation: { - kind: "markdown", - value: `\`\`\`\n${linePrefix + insertionText}\n\`\`\`\n ---\nSuggested by Tabby.`, - }, - textEdit: { - newText: wordPrefix + insertionText, - range: { - start: { line: position.line, character: position.character - wordPrefix.length }, - end: document.positionAt(item.range.end), - }, - }, - data: item.data, - }; - }), - }; - } -} diff --git a/clients/tabby-agent/src/TabbyAgent.ts b/clients/tabby-agent/src/TabbyAgent.ts index 0c393c8d013e..33794ce497ad 100644 --- a/clients/tabby-agent/src/TabbyAgent.ts +++ b/clients/tabby-agent/src/TabbyAgent.ts @@ -341,7 +341,7 @@ export class TabbyAgent extends EventEmitter implements Agent { } catch (error) { if (isUnauthorizedError(error)) { this.logger.debug(`Fetch server provided config request failed due to unauthorized. [${requestId}]`); - } else if (error! instanceof HttpError) { + } else if (!(error instanceof HttpError)) { this.logger.error(`Fetch server provided config request failed. [${requestId}]`, error); } } diff --git a/clients/tabby-agent/src/cli.ts b/clients/tabby-agent/src/cli.ts index ecf15af34f6f..0455192df25f 100644 --- a/clients/tabby-agent/src/cli.ts +++ b/clients/tabby-agent/src/cli.ts @@ -2,16 +2,16 @@ import { TabbyAgent } from "./TabbyAgent"; import { JsonLineServer } from "./JsonLineServer"; -import { LspServer } from "./LspServer"; +import { Server as LspServer } from "./lsp/Server"; const args = process.argv.slice(2); +const agent = new TabbyAgent(); let server; if (args.indexOf("--lsp") >= 0) { - server = new LspServer(); + server = new LspServer(agent); } else { server = new JsonLineServer(); + server.bind(agent); } -const agent = new TabbyAgent(); -server.bind(agent); server.listen(); diff --git a/clients/vscode/src/CodeSearchEngine.ts b/clients/tabby-agent/src/codeSearch/CodeSearchEngine.ts similarity index 73% rename from clients/vscode/src/CodeSearchEngine.ts rename to clients/tabby-agent/src/codeSearch/CodeSearchEngine.ts index 625cf54e9668..316e36bb27bb 100644 --- a/clients/vscode/src/CodeSearchEngine.ts +++ b/clients/tabby-agent/src/codeSearch/CodeSearchEngine.ts @@ -1,13 +1,15 @@ import * as Engine from "@orama/orama"; -import { Position, Range, TextDocument } from "vscode"; -import { extractNonReservedWordList } from "./utils"; +import { Position, Range } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { extractNonReservedWordList } from "../utils/string"; +import { isPositionBefore, isPositionAfter, unionRange, rangeInDocument } from "../utils/range"; -export type DocumentRange = { +export interface DocumentRange { document: TextDocument; range: Range; -}; +} -export type CodeSnippet = { +export interface CodeSnippet { // Which file does the snippet belongs to filepath: string; // (Not Indexed) The offset of the snippet in the file @@ -18,29 +20,32 @@ export type CodeSnippet = { language: string; // The semantic symbols extracted from the snippet symbols: string; -}; +} -export type ChunkingConfig = { +export interface ChunkingConfig { // max count for chunks in memory maxChunks: number; // chars count per code chunk chunkSize: number; // lines count overlap between neighbor chunks overlapLines: number; -}; +} -export type CodeSearchHit = { +export interface CodeSearchHit { snippet: CodeSnippet; score: number; -}; +} export class CodeSearchEngine { constructor(private config: ChunkingConfig) {} - private db: any | undefined = undefined; + private db: Engine.AnyOrama | undefined = undefined; private indexedDocumentRanges: (DocumentRange & { indexIds: string[] })[] = []; private async init() { + if (this.db) { + return; + } this.db = await Engine.create({ schema: { filepath: "string", @@ -61,7 +66,10 @@ export class CodeSearchEngine { if (!this.db) { await this.init(); } - return await Engine.insertMultiple(this.db, snippets); + if (this.db) { + return await Engine.insertMultiple(this.db, snippets); + } + return []; } private async remove(ids: string[]): Promise { @@ -72,33 +80,35 @@ export class CodeSearchEngine { } private async chunk(documentRange: DocumentRange): Promise { - const chunks: CodeSnippet[] = []; const document = documentRange.document; - const documentUriString = document.uri.toString(); - const range = document.validateRange(documentRange.range); - let positionStart = range.start; + const range = rangeInDocument(documentRange.range, document); + if (!range) { + return []; + } + const chunks: CodeSnippet[] = []; + let positionStart: Position = range.start; let positionEnd; do { const offset = document.offsetAt(positionStart); // move forward chunk size positionEnd = document.positionAt(offset + this.config.chunkSize); - if (positionEnd.isBefore(range.end)) { + if (isPositionBefore(positionEnd, range.end)) { // If have not reached the end, back to the last newline instead - positionEnd = positionEnd.with({ character: 0 }); + positionEnd = { line: positionEnd.line, character: 0 }; } if (positionEnd.line <= positionStart.line + this.config.overlapLines) { // In case of forward chunk size does not moved enough lines for overlap, force move that much lines - positionEnd = new Position(positionStart.line + this.config.overlapLines + 1, 0); + positionEnd = { line: positionStart.line + this.config.overlapLines + 1, character: 0 }; } - if (positionEnd.isAfter(range.end)) { + if (isPositionAfter(positionEnd, range.end)) { // If have passed the end, back to the end positionEnd = range.end; } - const text = document.getText(new Range(positionStart, positionEnd)); + const text = document.getText({ start: positionStart, end: positionEnd }); if (text.trim().length > 0) { chunks.push({ - filepath: documentUriString, + filepath: document.uri, offset: document.offsetAt(positionStart), fullText: text, language: document.languageId, @@ -107,8 +117,8 @@ export class CodeSearchEngine { } // move the start position to the next chunk start - positionStart = new Position(positionEnd.line - this.config.overlapLines, 0); - } while (chunks.length < this.config.maxChunks && positionEnd.isBefore(range.end)); + positionStart = { line: positionEnd.line - this.config.overlapLines, character: 0 }; + } while (chunks.length < this.config.maxChunks && isPositionBefore(positionEnd, range.end)); return chunks; } @@ -134,13 +144,14 @@ export class CodeSearchEngine { const indexToUpdate = this.indexedDocumentRanges.findIndex( (item) => item.document.uri.toString() === documentUriString, ); - if (indexToUpdate >= 0) { - // FIXME: union is not very good for merging two ranges have large distance between them - targetRange = targetRange.union(this.indexedDocumentRanges[indexToUpdate]!.range); + const documentRangeToUpdate = this.indexedDocumentRanges[indexToUpdate]; + if (documentRangeToUpdate) { + // FIXME: union is not perfect for merging two ranges have large distance between them + targetRange = unionRange(targetRange, documentRangeToUpdate.range); } const chunks = await this.chunk({ document, range: targetRange }); - if (indexToUpdate >= 0) { - await this.remove(this.indexedDocumentRanges[indexToUpdate]!.indexIds); + if (documentRangeToUpdate) { + await this.remove(documentRangeToUpdate.indexIds); this.indexedDocumentRanges.splice(indexToUpdate); } const indexIds = await this.insert(chunks); @@ -181,12 +192,10 @@ export class CodeSearchEngine { if (!this.db) { return []; } - const searchResult = await Engine.search(this.db, { + const searchResult = await Engine.search(this.db, { term: query, properties: ["symbols"], where: { - // FIXME: It seems this cannot exactly filtering using the filepaths - // So we do a manual filtering later filepath: options?.filepathsFilter, language: options?.languagesFilter, }, @@ -196,10 +205,10 @@ export class CodeSearchEngine { searchResult.hits // manual filtering .filter((hit) => { - if (options?.filepathsFilter && !options?.filepathsFilter.includes(hit.document.filepath)) { + if (options?.filepathsFilter && !options?.filepathsFilter.includes(hit.document["filepath"])) { return false; } - if (options?.languagesFilter && !options?.languagesFilter.includes(hit.document.language)) { + if (options?.languagesFilter && !options?.languagesFilter.includes(hit.document["language"])) { return false; } return true; @@ -207,7 +216,6 @@ export class CodeSearchEngine { .map((hit) => { return { snippet: hit.document, - // FIXME: Why there are many scores NaN? score: hit.score || 0, }; }) diff --git a/clients/vscode/src/RecentlyChangedCodeSearch.ts b/clients/tabby-agent/src/codeSearch/RecentlyChangedCodeSearch.ts similarity index 65% rename from clients/vscode/src/RecentlyChangedCodeSearch.ts rename to clients/tabby-agent/src/codeSearch/RecentlyChangedCodeSearch.ts index 36879b41ad60..f4d54125708c 100644 --- a/clients/vscode/src/RecentlyChangedCodeSearch.ts +++ b/clients/tabby-agent/src/codeSearch/RecentlyChangedCodeSearch.ts @@ -1,22 +1,25 @@ -import { Range, TextDocument, TextDocumentChangeEvent } from "vscode"; -import { getLogger } from "./logger"; +import { Range } from "vscode-languageserver"; +import { TextDocument, TextDocumentContentChangeEvent } from "vscode-languageserver-textdocument"; import { CodeSearchEngine, ChunkingConfig, DocumentRange } from "./CodeSearchEngine"; +import { getLogger } from "../logger"; +import { unionRange, rangeInDocument } from "../utils/range"; -type TextChangeCroppingWindowConfig = { +interface TextChangeCroppingWindowConfig { prefixLines: number; suffixLines: number; -}; +} -type TextChangeListenerConfig = { +interface TextChangeListenerConfig { checkingChangesInterval: number; changesDebouncingInterval: number; -}; +} export class RecentlyChangedCodeSearch { private readonly logger = getLogger("CodeSearch"); private codeSearchEngine: CodeSearchEngine; private pendingDocumentRanges: DocumentRange[] = []; + /* @ts-expect-error noUnusedLocals */ private indexingWorker: ReturnType; private didChangeEventDebouncingCache = new Map< @@ -37,45 +40,55 @@ export class RecentlyChangedCodeSearch { this.logger.trace("Created with config.", { config }); } - handleDidChangeTextDocument(event: TextDocumentChangeEvent) { + handleDidChangeTextDocument(event: { document: TextDocument; contentChanges: TextDocumentContentChangeEvent[] }) { const { document, contentChanges } = event; if (contentChanges.length < 1) { return; } - const documentUriString = document.uri.toString(); let ranges = []; - if (this.didChangeEventDebouncingCache.has(documentUriString)) { - const cache = this.didChangeEventDebouncingCache.get(documentUriString)!; - ranges.push(cache.documentRange.range); - clearTimeout(cache.timer); + if (this.didChangeEventDebouncingCache.has(document.uri)) { + const cache = this.didChangeEventDebouncingCache.get(document.uri); + if (cache) { + ranges.push(cache.documentRange.range); + clearTimeout(cache.timer); + } } ranges = ranges.concat( - contentChanges.map( - (change) => - new Range( - document.positionAt(change.rangeOffset), - document.positionAt(change.rangeOffset + change.text.length), - ), - ), + contentChanges + .map((change) => + "range" in change + ? { + start: change.range.start, + end: document.positionAt(document.offsetAt(change.range.start) + change.text.length), + } + : null, + ) + .filter((range): range is Range => range !== null), ); - const mergedEditedRange = ranges.reduce((a, b) => a.union(b)); + const mergedEditedRange = ranges.reduce((a, b) => unionRange(a, b)); // Expand the range to cropping window - const targetRange = document.validateRange( - new Range( - Math.max(0, mergedEditedRange.start.line - this.config.prefixLines), - 0, - Math.min(document.lineCount, mergedEditedRange.end.line + this.config.suffixLines + 1), - 0, - ), - ); + const expand: Range = { + start: { + line: Math.max(0, mergedEditedRange.start.line - this.config.prefixLines), + character: 0, + }, + end: { + line: Math.min(document.lineCount, mergedEditedRange.end.line + this.config.suffixLines + 1), + character: 0, + }, + }; + const targetRange = rangeInDocument(expand, document); + if (targetRange === null) { + return; + } const documentRange = { document, range: targetRange }; // A debouncing to avoid indexing the same document multiple times in a short time - this.didChangeEventDebouncingCache.set(documentUriString, { + this.didChangeEventDebouncingCache.set(document.uri, { documentRange, timer: setTimeout(() => { this.pendingDocumentRanges.push(documentRange); - this.didChangeEventDebouncingCache.delete(documentUriString); - this.logger.trace("Created indexing task:", { path: documentUriString, range: targetRange }); + this.didChangeEventDebouncingCache.delete(document.uri); + this.logger.trace("Created indexing task:", { documentRange }); }, this.config.changesDebouncingInterval), }); } diff --git a/clients/tabby-agent/src/lsp/Server.ts b/clients/tabby-agent/src/lsp/Server.ts new file mode 100644 index 000000000000..0cf241170021 --- /dev/null +++ b/clients/tabby-agent/src/lsp/Server.ts @@ -0,0 +1,1076 @@ +import { createConnection as nodeCreateConnection } from "vscode-languageserver/node"; +import { + createConnection as browserCreateConnection, + BrowserMessageReader, + BrowserMessageWriter, +} from "vscode-languageserver/browser"; +import { + Position, + Range, + Location, + ProposedFeatures, + CancellationToken, + RegistrationRequest, + UnregistrationRequest, + TextDocumentSyncKind, + TextDocumentPositionParams, + TextDocumentContentChangeEvent, + NotebookDocument, + NotebookDocuments, + NotebookCell, + CompletionParams, + CompletionTriggerKind, + CompletionItemKind, + InlineCompletionParams, + InlineCompletionTriggerKind, +} from "vscode-languageserver"; +import { + InitializeParams, + InitializeResult, + ClientInfo, + ClientCapabilities, + ServerCapabilities, + DidChangeConfigurationParams, + ClientProvidedConfig, + AgentServerConfigRequest, + AgentServerConfigSync, + DidChangeServerConfigParams, + ServerConfig, + AgentStatusRequest, + AgentStatusSync, + DidChangeStatusParams, + Status, + AgentIssuesRequest, + AgentIssuesSync, + DidUpdateIssueParams, + IssueList, + AgentIssueDetailRequest, + IssueDetailParams, + IssueDetailResult, + CompletionList, + CompletionItem, + InlineCompletionRequest, + InlineCompletionList, + InlineCompletionItem, + ChatFeatureRegistration, + GenerateCommitMessageRequest, + GenerateCommitMessageParams, + GenerateCommitMessageResult, + TelemetryEventNotification, + EventParams, + ChatFeatureNotAvailableError, + EditorOptions, + GitRepositoryRequest, + GitRepositoryParams, + GitRepository, + GitDiffRequest, + GitDiffParams, + GitDiffResult, + DataStoreGetRequest, + DataStoreGetParams, + DataStoreSetRequest, + DataStoreSetParams, + EditorOptionsRequest, + ReadFileRequest, + ReadFileParams, + LanguageSupportDeclarationRequest, + LanguageSupportSemanticTokensRangeRequest, +} from "./protocol"; +import { TextDocuments } from "./TextDocuments"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import deepEqual from "deep-equal"; +import type { + Agent, + AgentIssue, + ConfigUpdatedEvent, + StatusChangedEvent, + IssuesUpdatedEvent, + CompletionRequest, + CompletionResponse, + ClientProperties, +} from "../Agent"; +import type { PartialAgentConfig } from "../AgentConfig"; +import { isBrowser } from "../env"; +import { getLogger, Logger } from "../logger"; +import type { DataStore } from "../dataStore"; +import { name as agentName, version as agentVersion } from "../../package.json"; +import { RecentlyChangedCodeSearch } from "../codeSearch/RecentlyChangedCodeSearch"; +import { isPositionInRange, intersectionRange } from "../utils/range"; +import { extractNonReservedWordList } from "../utils/string"; +import { splitLines, isBlank } from "../utils"; + +export class Server { + private readonly logger = getLogger("LspServer"); + private readonly connection = isBrowser + ? browserCreateConnection(ProposedFeatures.all, new BrowserMessageReader(self), new BrowserMessageWriter(self)) + : nodeCreateConnection(ProposedFeatures.all); + + private readonly documents = new TextDocuments(TextDocument); + private readonly notebooks = new NotebookDocuments(this.documents); + private recentlyChangedCodeSearch: RecentlyChangedCodeSearch | undefined = undefined; + + private clientInfo?: ClientInfo | undefined | null; + private clientCapabilities?: ClientCapabilities | undefined | null; + private clientProvidedConfig?: ClientProvidedConfig | undefined | null; + private serverConfig: ServerConfig; + + constructor(private readonly agent: Agent) { + this.serverConfig = agent.getConfig().server; + // Lifecycle + this.connection.onInitialize(async (params) => { + return this.initialize(params); + }); + this.connection.onInitialized(async () => { + return this.initialized(); + }); + this.connection.onDidChangeConfiguration(async (params) => { + return this.updateConfiguration(params); + }); + this.connection.onShutdown(async () => { + return this.shutdown(); + }); + this.connection.onExit(async () => { + return this.exit(); + }); + // Agent + this.connection.onRequest(AgentServerConfigRequest.type, async () => { + return this.getServerConfig(); + }); + this.connection.onRequest(AgentStatusRequest.type, async () => { + return this.getStatus(); + }); + this.connection.onRequest(AgentIssuesRequest.type, async () => { + return this.getIssues(); + }); + this.connection.onRequest(AgentIssueDetailRequest.type, async (params) => { + return this.getIssueDetail(params); + }); + // Documents Sync + this.documents.listen(this.connection); + this.notebooks.listen(this.connection); + + // Completion + this.connection.onCompletion(async (params, token) => { + return this.provideCompletion(params, token); + }); + this.connection.onRequest(InlineCompletionRequest.type, async (params, token) => { + return this.provideInlineCompletion(params, token); + }); + // Chat + this.connection.onRequest(GenerateCommitMessageRequest.type, async (params, token) => { + return this.generateCommitMessage(params, token); + }); + // Telemetry + this.connection.onNotification(TelemetryEventNotification.type, async (param) => { + return this.event(param); + }); + } + + listen() { + this.connection.listen(); + } + + private async initialize(params: InitializeParams): Promise { + const clientInfo: ClientInfo | undefined = params.clientInfo; + this.clientInfo = clientInfo; + const clientCapabilities: ClientCapabilities = params.capabilities; + this.clientCapabilities = clientCapabilities; + const clientProvidedConfig: ClientProvidedConfig | undefined = params.initializationOptions?.config; + this.clientProvidedConfig = clientProvidedConfig; + const serverCapabilities: ServerCapabilities = { + textDocumentSync: { + openClose: true, + change: TextDocumentSyncKind.Incremental, + }, + notebookDocumentSync: { + notebookSelector: [ + { + notebook: "*", + }, + ], + }, + workspace: { + workspaceFolders: { + supported: true, + changeNotifications: true, + }, + }, + completionProvider: undefined, + inlineCompletionProvider: undefined, + tabby: { + chat: false, + }, + }; + + if (clientCapabilities.textDocument?.inlineCompletion) { + serverCapabilities.inlineCompletionProvider = true; + } else { + serverCapabilities.completionProvider = {}; + } + + await this.agent.initialize({ + config: this.createInitConfig(clientProvidedConfig), + clientProperties: this.createInitClientProperties(clientInfo, clientProvidedConfig), + dataStore: clientCapabilities.tabby?.dataStore ? this.createDataStore() : undefined, + loggers: [this.createLogger()], + }); + + if (this.agent.getServerHealthState()?.chat_model) { + serverCapabilities.tabby = { + ...serverCapabilities.tabby, + chat: true, + }; + } + + const result: InitializeResult = { + capabilities: serverCapabilities, + serverInfo: { + name: agentName, + version: agentVersion, + }, + }; + return result; + } + + private async initialized(): Promise { + const agentConfig = this.agent.getConfig(); + if (agentConfig.completion.prompt.collectSnippetsFromRecentChangedFiles.enabled) { + this.recentlyChangedCodeSearch = new RecentlyChangedCodeSearch( + agentConfig.completion.prompt.collectSnippetsFromRecentChangedFiles.indexing, + ); + this.documents.onDidChangeContent(async (params: unknown) => { + if (!params || typeof params !== "object" || !("document" in params) || !("contentChanges" in params)) { + return; + } + const event = params as { document: TextDocument; contentChanges: TextDocumentContentChangeEvent[] }; + this.recentlyChangedCodeSearch?.handleDidChangeTextDocument(event); + }); + } + + if (this.clientCapabilities?.tabby?.agent) { + this.agent.on("configUpdated", (event: ConfigUpdatedEvent) => { + if (!deepEqual(event.config.server, this.serverConfig)) { + const params: DidChangeServerConfigParams = { + server: event.config.server, + }; + this.connection.sendNotification(AgentServerConfigSync.type, params); + this.serverConfig = event.config.server; + } + }); + + this.agent.on("statusChanged", (event: StatusChangedEvent) => { + const params: DidChangeStatusParams = { + status: event.status, + }; + this.connection.sendNotification(AgentStatusSync.type, params); + + if (this.agent.getServerHealthState()?.chat_model) { + this.connection.sendRequest(RegistrationRequest.type, { + registrations: [ + { + id: ChatFeatureRegistration.type.method, + method: ChatFeatureRegistration.type.method, + }, + ], + }); + } else { + this.connection.sendRequest(UnregistrationRequest.type, { + unregisterations: [ + { + id: ChatFeatureRegistration.type.method, + method: ChatFeatureRegistration.type.method, + }, + ], + }); + } + }); + + this.agent.on("issuesUpdated", (event: IssuesUpdatedEvent) => { + const params: DidUpdateIssueParams = { + issues: event.issues, + }; + this.connection.sendNotification(AgentIssuesSync.type, params); + }); + } + } + + private async updateConfiguration(params: DidChangeConfigurationParams) { + const clientProvidedConfig: ClientProvidedConfig | null = params.settings; + if ( + clientProvidedConfig?.server?.endpoint !== undefined && + clientProvidedConfig.server.endpoint !== this.clientProvidedConfig?.server?.endpoint + ) { + if (clientProvidedConfig.server.endpoint.trim().length > 0) { + this.agent.updateConfig("server.endpoint", clientProvidedConfig.server.endpoint); + } else { + this.agent.clearConfig("server.endpoint"); + } + } + if ( + clientProvidedConfig?.server?.token !== undefined && + clientProvidedConfig.server.token !== this.clientProvidedConfig?.server?.token + ) { + if (clientProvidedConfig.server.token.trim().length > 0) { + this.agent.updateConfig("server.token", clientProvidedConfig.server.token); + } else { + this.agent.clearConfig("server.token"); + } + } + if ( + clientProvidedConfig?.anonymousUsageTracking?.disable !== undefined && + clientProvidedConfig.anonymousUsageTracking.disable !== this.clientProvidedConfig?.anonymousUsageTracking?.disable + ) { + if (clientProvidedConfig.anonymousUsageTracking.disable) { + this.agent.updateConfig("anonymousUsageTracking.disable", true); + } else { + this.agent.clearConfig("anonymousUsageTracking.disable"); + } + } + const clientType = this.getClientType(this.clientInfo); + if ( + clientProvidedConfig?.inlineCompletion?.triggerMode !== undefined && + clientProvidedConfig.inlineCompletion.triggerMode !== this.clientProvidedConfig?.inlineCompletion?.triggerMode + ) { + this.agent.updateClientProperties( + "user", + `${clientType}.triggerMode`, + clientProvidedConfig.inlineCompletion?.triggerMode, + ); + } + if ( + clientProvidedConfig?.keybindings !== undefined && + clientProvidedConfig.keybindings !== this.clientProvidedConfig?.keybindings + ) { + this.agent.updateClientProperties("user", `${clientType}.keybindings`, clientProvidedConfig.keybindings); + } + this.clientProvidedConfig = clientProvidedConfig; + } + + private async shutdown() { + await this.agent.finalize(); + } + + private exit() { + return process.exit(0); + } + + private async getServerConfig(): Promise { + const serverConfig = this.agent.getConfig().server; + return { + endpoint: serverConfig.endpoint, + token: serverConfig.token, + requestHeaders: serverConfig.requestHeaders, + }; + } + + private async getStatus(): Promise { + return this.agent.getStatus(); + } + + private async getIssues(): Promise { + return { issues: this.agent.getIssues() }; + } + + private async getIssueDetail(params: IssueDetailParams): Promise { + const detail = this.agent.getIssueDetail({ name: params.name }); + if (!detail) { + return null; + } + return { + name: detail.name, + helpMessage: this.buildHelpMessage(detail, params.helpMessageFormat), + }; + } + + private async provideCompletion(params: CompletionParams, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return null; + } + const abortController = new AbortController(); + token.onCancellationRequested(() => abortController.abort()); + try { + const result = await this.completionParamsToCompletionRequest(params, token); + if (!result) { + return null; + } + const response = await this.agent.provideCompletions(result.request, { signal: abortController.signal }); + return this.toCompletionList(response, params, result.additionalPrefixLength); + } catch (error) { + return null; + } + } + + private async provideInlineCompletion( + params: InlineCompletionParams, + token: CancellationToken, + ): Promise { + if (token.isCancellationRequested) { + return null; + } + const abortController = new AbortController(); + token.onCancellationRequested(() => abortController.abort()); + try { + const result = await this.inlineCompletionParamsToCompletionRequest(params, token); + if (!result) { + return null; + } + const response = await this.agent.provideCompletions(result.request, { signal: abortController.signal }); + return this.toInlineCompletionList(response, params, result.additionalPrefixLength); + } catch (error) { + return null; + } + } + + private async generateCommitMessage( + params: GenerateCommitMessageParams, + token: CancellationToken, + ): Promise { + if (token.isCancellationRequested) { + return null; + } + + if (!this.agent.getServerHealthState()?.chat_model) { + throw { name: "ChatFeatureNotAvailableError" } as ChatFeatureNotAvailableError; + } + const abortController = new AbortController(); + token.onCancellationRequested(() => abortController.abort()); + const { repository } = params; + let diffResult: GitDiffResult | undefined | null = undefined; + if (this.clientCapabilities?.tabby?.gitProvider) { + const params: GitDiffParams = { repository, cached: true }; + diffResult = await this.connection.sendRequest(GitDiffRequest.type, params); + if ( + !diffResult?.diff || + (typeof diffResult.diff === "string" && isBlank(diffResult.diff)) || + (Array.isArray(diffResult.diff) && isBlank(diffResult.diff.join(""))) + ) { + // Use uncached diff if cached diff is empty + const params: GitDiffParams = { repository, cached: false }; + diffResult = await this.connection.sendRequest(GitDiffRequest.type, params); + } + } else { + //FIXME: fallback to system `git` command + } + if (!diffResult || !diffResult.diff) { + return null; + } + try { + const commitMessage = await this.agent.generateCommitMessage(diffResult.diff, { signal: abortController.signal }); + return { commitMessage }; + } catch (error) { + return null; + } + } + + private async event(params: EventParams): Promise { + try { + const request = { + type: params.type, + select_kind: params.selectKind, + completion_id: params.eventId.completionId, + choice_index: params.eventId.choiceIndex, + view_id: params.viewId, + elapsed: params.elapsed, + }; + await this.agent.postEvent(request); + } catch (error) { + return; + } + } + + private createInitConfig(clientProvidedConfig: ClientProvidedConfig | undefined): PartialAgentConfig { + const config: PartialAgentConfig = {}; + if (clientProvidedConfig?.server?.endpoint && clientProvidedConfig.server.endpoint.trim().length > 0) { + config.server = { + endpoint: clientProvidedConfig.server.endpoint, + }; + } + if (clientProvidedConfig?.server?.token && clientProvidedConfig.server.token.trim().length > 0) { + if (config.server) { + config.server.token = clientProvidedConfig.server.token; + } else { + config.server = { + token: clientProvidedConfig.server.token, + }; + } + } + if (clientProvidedConfig?.anonymousUsageTracking?.disable !== undefined) { + config.anonymousUsageTracking = { + disable: clientProvidedConfig.anonymousUsageTracking.disable, + }; + } + return config; + } + + private createInitClientProperties( + clientInfo?: ClientInfo, + clientProvidedConfig?: ClientProvidedConfig, + ): ClientProperties { + const clientType = this.getClientType(clientInfo); + return { + user: { + [clientType]: { + triggerMode: clientProvidedConfig?.inlineCompletion?.triggerMode, + keybindings: clientProvidedConfig?.keybindings, + }, + }, + session: { + client: `${clientInfo?.name} ${clientInfo?.version ?? ""}`, + ide: { + name: clientInfo?.name, + version: clientInfo?.version, + }, + tabby_plugin: clientInfo?.tabbyPlugin ?? { + name: `${agentName} (LSP)`, + version: agentVersion, + }, + }, + }; + } + + private getClientType(clientInfo?: ClientInfo | undefined | null): string { + if (!clientInfo) { + return "unknown"; + } + if (clientInfo.tabbyPlugin?.name == "TabbyML.vscode-tabby") { + return "vscode"; + } else if (clientInfo.tabbyPlugin?.name == "com.tabbyml.intellij-tabby") { + return "intellij"; + } else if (clientInfo.tabbyPlugin?.name == "TabbyML/vim-tabby") { + return "vim"; + } + return clientInfo.name; + } + + private createDataStore(): DataStore { + const dataStore = { + data: {}, + load: async () => { + const params: DataStoreGetParams = { key: "data" }; + dataStore.data = await this.connection.sendRequest(DataStoreGetRequest.type, params); + }, + save: async () => { + const params: DataStoreSetParams = { key: "data", value: dataStore.data }; + await this.connection.sendRequest(DataStoreSetRequest.type, params); + }, + }; + return dataStore; + } + + private createLogger(): Logger { + return { + error: (msg: string, error: any) => { + const errorMsg = + error instanceof Error + ? `[${error.name}] ${error.message} \n${error.stack}` + : JSON.stringify(error, undefined, 2); + this.connection.console.error(`${msg} ${errorMsg}`); + }, + warn: (msg: string) => { + this.connection.console.warn(msg); + }, + info: (msg: string) => { + this.connection.console.info(msg); + }, + debug: (msg: string) => { + this.connection.console.debug(msg); + }, + trace: () => {}, + }; + } + + private async textDocumentPositionParamsToCompletionRequest( + params: TextDocumentPositionParams, + token?: CancellationToken, + ): Promise<{ request: CompletionRequest; additionalPrefixLength?: number } | null> { + const { textDocument, position } = params; + + this.logger.trace("Building completion context...", { uri: textDocument.uri }); + + const document = this.documents.get(textDocument.uri); + if (!document) { + return null; + } + + const request: CompletionRequest = { + filepath: document.uri, + language: document.languageId, + text: document.getText(), + position: document.offsetAt(position), + }; + + const notebookCell = this.notebooks.getNotebookCell(textDocument.uri); + let additionalContext: { prefix: string; suffix: string } | undefined = undefined; + if (notebookCell) { + this.logger.trace("Notebook cell found:", { notebookCell }); + additionalContext = this.buildNotebookAdditionalContext(document, notebookCell); + } + if (additionalContext) { + request.text = additionalContext.prefix + request.text + additionalContext.suffix; + request.position += additionalContext.prefix.length; + } + + if (this.clientCapabilities?.tabby?.editorOptions) { + const editorOptions: EditorOptions | null = await this.connection.sendRequest( + EditorOptionsRequest.type, + { + uri: params.textDocument.uri, + }, + token, + ); + request.indentation = editorOptions?.indentation; + } + if (this.clientCapabilities?.workspace) { + const workspaceFolders = await this.connection.workspace.getWorkspaceFolders(); + request.workspace = workspaceFolders?.find((folder) => document.uri.startsWith(folder.uri))?.uri; + } + if (this.clientCapabilities?.tabby?.gitProvider) { + const params: GitRepositoryParams = { uri: document.uri }; + const repo: GitRepository | null = await this.connection.sendRequest(GitRepositoryRequest.type, params, token); + if (repo) { + request.git = { + root: repo.root, + remotes: repo.remoteUrl ? [{ name: "", url: repo.remoteUrl }] : repo.remotes ?? [], + }; + } + } else { + //FIXME: fallback to system `git` command + } + if (this.clientCapabilities?.tabby?.languageSupport) { + request.declarations = await this.collectDeclarationSnippets(document, position, token); + } + if (this.recentlyChangedCodeSearch) { + request.relevantSnippetsFromChangedFiles = await this.collectSnippetsFromRecentlyChangedFiles(document, position); + } + this.logger.trace("Completed completion context:", { request }); + return { request, additionalPrefixLength: additionalContext?.prefix.length }; + } + + private buildNotebookAdditionalContext( + textDocument: TextDocument, + notebookCell: NotebookCell, + ): { prefix: string; suffix: string } | undefined { + this.logger.trace("Building notebook additional context..."); + const notebook = this.notebooks.findNotebookDocumentForCell(notebookCell); + if (!notebook) { + return notebook; + } + const index = notebook.cells.indexOf(notebookCell); + const prefix = this.buildNotebookContext(notebook, 0, index, textDocument.languageId) + "\n\n"; + const suffix = + "\n\n" + this.buildNotebookContext(notebook, index + 1, notebook.cells.length, textDocument.languageId); + + this.logger.trace("Notebook additional context:", { prefix, suffix }); + return { prefix, suffix }; + } + + private notebookLanguageComments: { [languageId: string]: (code: string) => string } = { + markdown: (code) => "```\n" + code + "\n```", + python: (code) => + code + .split("\n") + .map((l) => "# " + l) + .join("\n"), + }; + + private buildNotebookContext(notebook: NotebookDocument, from: number, to: number, languageId: string): string { + return notebook.cells + .slice(from, to) + .map((cell) => { + const textDocument = this.notebooks.getCellTextDocument(cell); + if (!textDocument) { + return ""; + } + if (textDocument.languageId === languageId) { + return textDocument.getText(); + } else if (Object.keys(this.notebookLanguageComments).includes(languageId)) { + return this.notebookLanguageComments[languageId]?.(textDocument.getText()) ?? ""; + } else { + return ""; + } + }) + .join("\n\n"); + } + + private async collectDeclarationSnippets( + textDocument: TextDocument, + position: Position, + token?: CancellationToken, + ): Promise<{ filepath: string; text: string; offset?: number }[] | undefined> { + const agentConfig = this.agent.getConfig(); + if (!agentConfig.completion.prompt.fillDeclarations.enabled) { + return; + } + this.logger.debug("Collecting declaration snippets..."); + this.logger.trace("Collecting snippets for:", { textDocument, position }); + // Find symbol positions in the previous lines + const prefixRange: Range = { + start: { line: Math.max(0, position.line - agentConfig.completion.prompt.maxPrefixLines), character: 0 }, + end: { line: position.line, character: position.character }, + }; + const extractedSymbols = await this.extractSemanticTokenPositions( + { + uri: textDocument.uri, + range: prefixRange, + }, + token, + ); + if (!extractedSymbols) { + // FIXME: fallback to simple split words positions + return undefined; + } + const allowedSymbolTypes = [ + "class", + "decorator", + "enum", + "function", + "interface", + "macro", + "method", + "namespace", + "struct", + "type", + "typeParameter", + ]; + const symbols = extractedSymbols.filter((symbol) => allowedSymbolTypes.includes(symbol.type ?? "")); + this.logger.trace("Found symbols in prefix text:", { symbols }); + + // Loop through the symbol positions backwards + const snippets: { filepath: string; text: string; offset?: number }[] = []; + const snippetLocations: Location[] = []; + for (let symbolIndex = symbols.length - 1; symbolIndex >= 0; symbolIndex--) { + if (snippets.length >= agentConfig.completion.prompt.fillDeclarations.maxSnippets) { + // Stop collecting snippets if the max number of snippets is reached + break; + } + const symbolPosition = symbols[symbolIndex]?.position; + if (!symbolPosition) { + continue; + } + const result = await this.connection.sendRequest( + LanguageSupportDeclarationRequest.type, + { + textDocument: { uri: textDocument.uri }, + position: symbolPosition, + }, + token, + ); + if (!result) { + continue; + } + const item = Array.isArray(result) ? result[0] : result; + if (!item) { + continue; + } + const location: Location = { + uri: "targetUri" in item ? item.targetUri : item.uri, + range: "targetRange" in item ? item.targetRange : item.range, + }; + this.logger.trace("Processing declaration location...", { location }); + if (location.uri == textDocument.uri && isPositionInRange(location.range.start, prefixRange)) { + // this symbol's declaration is already contained in the prefix range + // this also includes the case of the symbol's declaration is at this position itself + this.logger.trace("Skipping snippet as it is contained in the prefix."); + continue; + } + if ( + snippetLocations.find( + (collectedLocation) => + location.uri == collectedLocation.uri && intersectionRange(location.range, collectedLocation.range), + ) + ) { + this.logger.trace("Skipping snippet as it is already collected."); + continue; + } + this.logger.trace("Prepare to fetch text content..."); + let text: string | undefined = undefined; + const targetDocument = this.documents.get(location.uri); + if (targetDocument) { + this.logger.trace("Fetching text content from synced text document.", { targetDocument }); + text = targetDocument.getText(location.range); + this.logger.trace("Fetched text content from synced text document.", { text }); + } else if (this.clientCapabilities?.tabby?.workspaceFileSystem) { + const params: ReadFileParams = { + uri: location.uri, + format: "text", + range: { + start: { line: location.range.start.line, character: 0 }, + end: { line: location.range.end.line, character: location.range.end.character }, + }, + }; + this.logger.trace("Fetching text content from ReadFileRequest.", { params }); + const result = await this.connection.sendRequest(ReadFileRequest.type, params, token); + this.logger.trace("Fetched text content from ReadFileRequest.", { result }); + text = result?.text; + } else { + // FIXME: fallback to fs + } + if (!text) { + this.logger.trace("Cannot fetch text content, continue to next.", { result }); + continue; + } + const maxChars = agentConfig.completion.prompt.fillDeclarations.maxCharsPerSnippet; + if (text.length > maxChars) { + // crop the text to fit within the chars limit + text = text.slice(0, maxChars); + const lastNewLine = text.lastIndexOf("\n"); + if (lastNewLine > 0) { + text = text.slice(0, lastNewLine + 1); + } + } + if (text.length > 0) { + this.logger.trace("Collected declaration snippet:", { text }); + snippets.push({ filepath: location.uri, offset: targetDocument?.offsetAt(position), text }); + snippetLocations.push(location); + } + } + this.logger.debug("Completed collecting declaration snippets."); + this.logger.trace("Collected snippets:", snippets); + return snippets; + } + + private async extractSemanticTokenPositions( + location: Location, + token?: CancellationToken, + ): Promise< + | { + position: Position; + type: string | undefined; + }[] + | undefined + > { + const result = await this.connection.sendRequest( + LanguageSupportSemanticTokensRangeRequest.type, + { + textDocument: { uri: location.uri }, + range: location.range, + }, + token, + ); + if (!result || !result.legend || !result.legend.tokenTypes || !result.tokens || !result.tokens.data) { + return undefined; + } + const { legend, tokens } = result; + const data: number[] = Array.isArray(tokens.data) ? tokens.data : Object.values(tokens.data); + const semanticSymbols: { + position: Position; + type: string | undefined; + }[] = []; + let line = 0; + let character = 0; + for (let i = 0; i + 4 < data.length; i += 5) { + const deltaLine = data[i]; + const deltaChar = data[i + 1]; + // i + 2 is token length, not used here + const typeIndex = data[i + 3]; + // i + 4 is type modifiers, not used here + if (deltaLine === undefined || deltaChar === undefined || typeIndex === undefined) { + break; + } + + line += deltaLine; + if (deltaLine > 0) { + character = deltaChar; + } else { + character += deltaChar; + } + semanticSymbols.push({ + position: { line, character }, + type: legend.tokenTypes[typeIndex], + }); + } + return semanticSymbols; + } + + private async collectSnippetsFromRecentlyChangedFiles( + textDocument: TextDocument, + position: Position, + ): Promise<{ filepath: string; offset: number; text: string; score: number }[] | undefined> { + const agentConfig = this.agent.getConfig(); + if ( + !agentConfig.completion.prompt.collectSnippetsFromRecentChangedFiles.enabled || + !this.recentlyChangedCodeSearch + ) { + return undefined; + } + this.logger.debug("Collecting snippets from recently changed files..."); + this.logger.trace("Collecting snippets for:", { textDocument, position }); + const prefixRange: Range = { + start: { line: Math.max(0, position.line - agentConfig.completion.prompt.maxPrefixLines), character: 0 }, + end: { line: position.line, character: position.character }, + }; + const prefixText = textDocument.getText(prefixRange); + const query = extractNonReservedWordList(prefixText); + const snippets = await this.recentlyChangedCodeSearch.collectRelevantSnippets( + query, + textDocument, + agentConfig.completion.prompt.collectSnippetsFromRecentChangedFiles.maxSnippets, + ); + this.logger.debug("Completed collecting snippets from recently changed files."); + this.logger.trace("Collected snippets:", snippets); + return snippets; + } + + private async completionParamsToCompletionRequest( + params: CompletionParams, + token?: CancellationToken, + ): Promise<{ request: CompletionRequest; additionalPrefixLength?: number } | null> { + const result = await this.textDocumentPositionParamsToCompletionRequest(params, token); + if (!result) { + return null; + } + result.request.manually = params.context?.triggerKind === CompletionTriggerKind.Invoked; + return result; + } + + private async inlineCompletionParamsToCompletionRequest( + params: InlineCompletionParams, + token?: CancellationToken, + ): Promise<{ request: CompletionRequest; additionalPrefixLength?: number } | null> { + const result = await this.textDocumentPositionParamsToCompletionRequest(params, token); + if (!result) { + return null; + } + result.request.manually = params.context?.triggerKind === InlineCompletionTriggerKind.Invoked; + return result; + } + + private toCompletionList( + response: CompletionResponse, + documentPosition: TextDocumentPositionParams, + additionalPrefixLength: number = 0, + ): CompletionList | null { + const { textDocument, position } = documentPosition; + const document = this.documents.get(textDocument.uri); + if (!document) { + return null; + } + + // Get word prefix if cursor is at end of a word + const linePrefix = document.getText({ + start: { line: position.line, character: 0 }, + end: position, + }); + const wordPrefix = linePrefix.match(/(\w+)$/)?.[0] ?? ""; + + return { + isIncomplete: response.isIncomplete, + items: response.items.map((item): CompletionItem => { + const insertionText = item.insertText.slice( + document.offsetAt(position) - (item.range.start - additionalPrefixLength), + ); + + const lines = splitLines(insertionText); + const firstLine = lines[0] || ""; + const secondLine = lines[1] || ""; + return { + label: wordPrefix + firstLine, + labelDetails: { + detail: secondLine, + description: "Tabby", + }, + kind: CompletionItemKind.Text, + documentation: { + kind: "markdown", + value: `\`\`\`\n${linePrefix + insertionText}\n\`\`\`\n ---\nSuggested by Tabby.`, + }, + textEdit: { + newText: wordPrefix + insertionText, + range: { + start: { line: position.line, character: position.character - wordPrefix.length }, + end: document.positionAt(item.range.end - additionalPrefixLength), + }, + }, + data: item.data, + }; + }), + }; + } + + private toInlineCompletionList( + response: CompletionResponse, + documentPosition: TextDocumentPositionParams, + additionalPrefixLength: number = 0, + ): InlineCompletionList | null { + const { textDocument } = documentPosition; + const document = this.documents.get(textDocument.uri); + if (!document) { + return null; + } + + return { + isIncomplete: response.isIncomplete, + items: response.items.map((item): InlineCompletionItem => { + return { + insertText: item.insertText, + range: { + start: document.positionAt(item.range.start - additionalPrefixLength), + end: document.positionAt(item.range.end - additionalPrefixLength), + }, + data: item.data, + }; + }), + }; + } + + private buildHelpMessage(issueDetail: AgentIssue, format?: "markdown"): string | undefined { + if (format !== "markdown") { + return undefined; + } + if (issueDetail.name == "connectionFailed") { + return issueDetail.message; + } + + let statsMessage = ""; + if (issueDetail.name == "slowCompletionResponseTime") { + const stats = issueDetail.completionResponseStats; + if (stats && stats["responses"] && stats["averageResponseTime"]) { + statsMessage = `The average response time of recent ${stats["responses"]} completion requests is ${Number( + stats["averageResponseTime"], + ).toFixed(0)}ms.\n\n`; + } + } + + if (issueDetail.name == "highCompletionTimeoutRate") { + const stats = issueDetail.completionResponseStats; + if (stats && stats["total"] && stats["timeouts"]) { + statsMessage = `${stats["timeouts"]} of ${stats["total"]} completion requests timed out.\n\n`; + } + } + + let helpMessageForRunningLargeModelOnCPU = ""; + const serverHealthState = this.agent.getServerHealthState(); + if (serverHealthState?.device === "cpu" && serverHealthState?.model?.match(/[0-9.]+B$/)) { + helpMessageForRunningLargeModelOnCPU += + `Your Tabby server is running model ${serverHealthState?.model} on CPU. ` + + "This model may be performing poorly due to its large parameter size, please consider trying smaller models or switch to GPU. " + + "You can find a list of recommend models in the online documentation.\n"; + } + let commonHelpMessage = ""; + const host = new URL(this.serverConfig.endpoint).host; + if (helpMessageForRunningLargeModelOnCPU.length == 0) { + commonHelpMessage += ` - The running model ${ + serverHealthState?.model ?? "" + } may be performing poorly due to its large parameter size. `; + commonHelpMessage += + "Please consider trying smaller models. You can find a list of recommend models in the online documentation.\n"; + } + if (!(host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("0.0.0.0"))) { + commonHelpMessage += " - A poor network connection. Please check your network and proxy settings.\n"; + commonHelpMessage += " - Server overload. Please contact your Tabby server administrator for assistance.\n"; + } + let helpMessage = ""; + if (helpMessageForRunningLargeModelOnCPU.length > 0) { + helpMessage += helpMessageForRunningLargeModelOnCPU + "\n"; + if (commonHelpMessage.length > 0) { + helpMessage += "Other possible causes of this issue: \n"; + helpMessage += commonHelpMessage; + } + } else { + // commonHelpMessage should not be empty here + helpMessage += "Possible causes of this issue: \n"; + helpMessage += commonHelpMessage; + } + return statsMessage + helpMessage; + } +} diff --git a/clients/tabby-agent/src/lsp/TextDocuments.ts b/clients/tabby-agent/src/lsp/TextDocuments.ts new file mode 100644 index 000000000000..e9f52d0fd004 --- /dev/null +++ b/clients/tabby-agent/src/lsp/TextDocuments.ts @@ -0,0 +1,45 @@ +import { + Disposable, + DocumentUri, + TextDocumentsConfiguration, + DidChangeTextDocumentParams, + TextDocuments as LspTextDocuments, +} from "vscode-languageserver"; +import { TextDocumentConnection } from "vscode-languageserver/lib/common/textDocuments"; + +export class TextDocuments< + T extends { + uri: DocumentUri; + }, +> extends LspTextDocuments { + constructor(configuration: TextDocumentsConfiguration) { + super(configuration); + } + + override listen(connection: TextDocumentConnection): Disposable { + const disposables: Disposable[] = []; + disposables.push(super.listen(connection)); + // override onDidChangeTextDocument listener + disposables.push( + connection.onDidChangeTextDocument((params: DidChangeTextDocumentParams) => { + const { textDocument, contentChanges } = params; + if (contentChanges.length === 0) { + return; + } + const { version } = textDocument; + if (version === null || version === undefined) { + throw new Error(`Received document change event for ${textDocument.uri} without valid version identifier`); + } + let document = this.get(textDocument.uri); + if (document !== undefined) { + document = this["_configuration"].update(document, contentChanges, version); + this["_syncedDocuments"].set(textDocument.uri, document); + this["_onDidChangeContent"].fire(Object.freeze({ document: document, contentChanges })); + } + }), + ); + return Disposable.create(() => { + disposables.forEach((disposable) => disposable.dispose()); + }); + } +} diff --git a/clients/tabby-agent/src/lsp/index.ts b/clients/tabby-agent/src/lsp/index.ts new file mode 100644 index 000000000000..ff070da6f21b --- /dev/null +++ b/clients/tabby-agent/src/lsp/index.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { TabbyAgent } from "../TabbyAgent"; +import { Server } from "./Server"; + +const server = new Server(new TabbyAgent()); +server.listen(); diff --git a/clients/tabby-agent/src/lsp/protocol.ts b/clients/tabby-agent/src/lsp/protocol.ts new file mode 100644 index 000000000000..889c1bb8ddf8 --- /dev/null +++ b/clients/tabby-agent/src/lsp/protocol.ts @@ -0,0 +1,672 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import { + ProtocolRequestType0, + ProtocolRequestType, + ProtocolNotificationType, + RegistrationType, + MessageDirection, + URI, + Range, + InitializeRequest as LspInitializeRequest, + InitializeParams as LspInitializeParams, + InitializeResult as LspInitializeResult, + InitializeError, + ClientCapabilities as LspClientCapabilities, + ServerCapabilities as LspServerCapabilities, + ConfigurationRequest as LspConfigurationRequest, + DidChangeConfigurationNotification as LspDidChangeConfigurationNotification, + DidChangeConfigurationParams as LspDidChangeConfigurationParams, + CompletionRequest as LspCompletionRequest, + CompletionParams, + CompletionList as LspCompletionList, + CompletionItem as LspCompletionItem, + InlineCompletionRequest as LspInlineCompletionRequest, + InlineCompletionParams, + InlineCompletionList as LspInlineCompletionList, + InlineCompletionItem as LspInlineCompletionItem, + DeclarationParams, + Declaration, + LocationLink, + SemanticTokensRangeParams, + SemanticTokens, + SemanticTokensLegend, +} from "vscode-languageserver-protocol"; + +/** + * Extends LSP method Initialize Request(↩️) + * + * - method: `initialize` + * - params: {@link InitializeParams} + * - result: {@link InitializeResult} + */ +export namespace InitializeRequest { + export const method = LspInitializeRequest.method; + export const messageDirection = LspInitializeRequest.messageDirection; + export const type = new ProtocolRequestType(method); +} + +export type InitializeParams = LspInitializeParams & { + clientInfo?: ClientInfo; + initializationOptions?: { + config?: ClientProvidedConfig; + }; + capabilities: ClientCapabilities; +}; + +export type InitializeResult = LspInitializeResult & { + capabilities: ServerCapabilities; +}; + +/** + * [Tabby] Defines the name and version information of the IDE and the tabby plugin. + */ +export type ClientInfo = { + name: string; + version?: string; + tabbyPlugin?: { + name: string; + version?: string; + }; +}; + +export type ClientCapabilities = LspClientCapabilities & { + tabby?: { + /** + * The client supports: + * - `tabby/agent/didChangeStatus` + * - `tabby/agent/didUpdateIssue` + * This capability indicates that client support receiving agent notifications. + */ + agent?: boolean; + /** + * The client supports: + * - `tabby/workspaceFileSystem/readFile` + * This capability improves the workspace code snippets context (RAG). + * When not provided, the server will try to fallback to NodeJS provided `fs` module, + * which is not available in the browser. + */ + workspaceFileSystem?: boolean; + /** + * The client supports: + * - `tabby/dataStore/get` + * - `tabby/dataStore/set` + * When not provided, the server will try to fallback to the default data store, + * a file-based data store (~/.tabby-client/agent/data.json), which is not available in the browser. + */ + dataStore?: boolean; + /** + * The client supports: + * - `tabby/languageSupport/textDocument/declaration` + * - `tabby/languageSupport/textDocument/semanticTokens/range` + * This capability improves the workspace code snippets context (RAG). + */ + languageSupport?: boolean; + /** + * The client supports: + * - `tabby/git/repository` + * - `tabby/git/diff` + * This capability improves the workspace git repository context (RAG). + * When not provided, the server will try to fallback to the default git provider, + * which running system `git` command, not available if cannot execute `git` command, + * not available in the browser. + */ + gitProvider?: boolean; + /** + * The client supports: + * - `tabby/editorOptions` + * This capability improves the completion formatting. + */ + editorOptions?: boolean; + }; +}; + +export type ServerCapabilities = LspServerCapabilities & { + tabby?: { + /** + * The server supports: + * - `tabby/chat/generateCommitMessage` + * See {@link ChatFeatureRegistration} + */ + chat?: boolean; + }; +}; + +export namespace ChatFeatureRegistration { + export const type = new RegistrationType("tabby/chat"); +} + +/** + * Extends LSP method Configuration Request(↪️) + * + * - method: `workspace/configuration` + * - params: any + * - result: {@link ClientProvidedConfig}[] (the array contains only one config) + */ +export namespace ConfigurationRequest { + export const method = LspConfigurationRequest.method; + export const messageDirection = LspConfigurationRequest.messageDirection; + export const type = new ProtocolRequestType(method); +} + +/** + * [Tabby] Defines the config supported to be changed on the client side (IDE). + */ +export type ClientProvidedConfig = { + /** + * Specifies the endpoint and token for connecting to the Tabby server. + */ + server?: { + endpoint?: string; + token?: string; + }; + /** + * Trigger mode should be implemented on the client side. + * Sending this config to the server is for telemetry purposes. + */ + inlineCompletion?: { + triggerMode?: "auto" | "manual"; + }; + /** + * Keybindings should be implemented on the client side. + * Sending this config to the server is for telemetry purposes. + */ + keybindings?: "default" | "tabby-style" | "customize"; + /** + * Controls whether the telemetry is enabled or not. + */ + anonymousUsageTracking?: { + disable?: boolean; + }; +}; + +/** + * Extends LSP method DidChangeConfiguration Notification(➡️) + * - method: `workspace/didChangeConfiguration` + * - params: {@link DidChangeConfigurationParams} + * - result: void + */ +export namespace DidChangeConfigurationNotification { + export const method = LspDidChangeConfigurationNotification.method; + export const messageDirection = LspDidChangeConfigurationNotification.messageDirection; + export const type = new ProtocolNotificationType(method); +} + +export type DidChangeConfigurationParams = LspDidChangeConfigurationParams & { + settings: ClientProvidedConfig | null; +}; + +/** + * Extends LSP method Completion Request(↩️) + * + * Note: Tabby provides this method capability *only* when the client has *NO* `textDocument/inlineCompletion` capability. + * - method: `textDocument/completion` + * - params: {@link CompletionParams} + * - result: {@link CompletionList} | null + */ +export namespace CompletionRequest { + export const method = LspCompletionRequest.method; + export const messageDirection = LspCompletionRequest.messageDirection; + export const type = new ProtocolRequestType(method); +} + +export type CompletionList = LspCompletionList & { + items: CompletionItem[]; +}; + +export type CompletionItem = LspCompletionItem & { + data?: { + /** + * The eventId is for telemetry purposes, should be used in `tabby/telemetry/event`. + */ + eventId?: CompletionEventId; + }; +}; + +export type CompletionEventId = { + completionId: string; + choiceIndex: number; +}; + +/** + * Extends LSP method Inline Completion Request(↩️) + * + * Note: Tabby provides this method capability only when the client has `textDocument/inlineCompletion` capability. + * - method: `textDocument/inlineCompletion` + * - params: {@link InlineCompletionParams} + * - result: {@link InlineCompletionList} | null + */ +export namespace InlineCompletionRequest { + export const method = LspInlineCompletionRequest.method; + export const messageDirection = LspInlineCompletionRequest.messageDirection; + export const type = new ProtocolRequestType( + method, + ); +} + +export type InlineCompletionList = LspInlineCompletionList & { + isIncomplete: boolean; + items: InlineCompletionItem[]; +}; + +export type InlineCompletionItem = LspInlineCompletionItem & { + data?: { + /** + * The eventId is for telemetry purposes, should be used in `tabby/telemetry/event`. + */ + eventId?: CompletionEventId; + }; +}; + +/** + * [Tabby] GenerateCommitMessage Request(↩️) + * + * This method is sent from the client to the server to generate a commit message for a git repository. + * - method: `tabby/chat/generateCommitMessage` + * - params: {@link GenerateCommitMessageParams} + * - result: {@link GenerateCommitMessageResult} | null + * - error: {@link ChatFeatureNotAvailableError} + */ +export namespace GenerateCommitMessageRequest { + export const method = "tabby/chat/generateCommitMessage"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType< + GenerateCommitMessageParams, + GenerateCommitMessageResult | null, + ChatFeatureNotAvailableError, + void, + void + >(method); +} + +export type GenerateCommitMessageParams = { + /** + * The root URI of the git repository. + */ + repository: URI; +}; + +export type GenerateCommitMessageResult = { + commitMessage: string; +}; + +export type ChatFeatureNotAvailableError = { + name: "ChatFeatureNotAvailableError"; +}; + +/** + * [Tabby] Telemetry Event Notification(➡️) + * + * This method is sent from the client to the server for telemetry purposes. + * - method: `tabby/telemetry/event` + * - params: {@link EventParams} + * - result: void + */ +export namespace TelemetryEventNotification { + export const method = "tabby/telemetry/event"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolNotificationType(method); +} + +export type EventParams = { + type: "view" | "select" | "dismiss"; + selectKind?: "line"; + eventId: CompletionEventId; + viewId?: string; + elapsed?: number; +}; + +/** + * [Tabby] DidChangeServerConfig Notification(⬅️) + * + * This method is sent from the server to the client to notify the current configuration + * for connecting to the Tabby server has changed. + * - method: `tabby/agent/didChangeServerConfig` + * - params: {@link DidChangeServerConfigParams} + * - result: void + */ +export namespace AgentServerConfigSync { + export const method = "tabby/agent/didChangeServerConfig"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolNotificationType(method); +} + +export type DidChangeServerConfigParams = { + server: ServerConfig; +}; + +export type ServerConfig = { + endpoint: string; + token: string; + requestHeaders: Record | null; +}; + +/** + * [Tabby] Server Config Request(↩️) + * + * This method is sent from the client to the server to check the current configuration + * for connecting to the Tabby server. + * - method: `tabby/agent/server` + * - params: none + * - result: {@link ServerConfig} + */ +export namespace AgentServerConfigRequest { + export const method = "tabby/agent/server"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType0(method); +} + +/** + * [Tabby] DidChangeStatus Notification(⬅️) + * + * This method is sent from the server to the client to notify the client about the status of the server. + * - method: `tabby/agent/didChangeStatus` + * - params: {@link DidChangeStatusParams} + * - result: void + */ +export namespace AgentStatusSync { + export const method = "tabby/agent/didChangeStatus"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolNotificationType(method); +} + +export type DidChangeStatusParams = { + status: Status; +}; + +export type Status = "notInitialized" | "ready" | "disconnected" | "unauthorized" | "finalized"; + +/** + * [Tabby] Status Request(↩️) + * + * This method is sent from the client to the server to check the current status of the server. + * - method: `tabby/agent/status` + * - params: none + * - result: {@link Status} + */ +export namespace AgentStatusRequest { + export const method = "tabby/agent/status"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType0(method); +} + +/** + * [Tabby] DidUpdateIssue Notification(⬅️) + * + * This method is sent from the server to the client to notify the client about the current issues. + * - method: `tabby/agent/didUpdateIssues` + * - params: {@link DidUpdateIssueParams} + * - result: void + */ +export namespace AgentIssuesSync { + export const method = "tabby/agent/didUpdateIssues"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolNotificationType(method); +} + +export type DidUpdateIssueParams = IssueList; + +export type IssueList = { + issues: IssueName[]; +}; + +export type IssueName = "slowCompletionResponseTime" | "highCompletionTimeoutRate" | "connectionFailed"; + +/** + * [Tabby] Issues Request(↩️) + * + * This method is sent from the client to the server to check if there is any issue. + * - method: `tabby/agent/issues` + * - params: none + * - result: {@link IssueList} + */ +export namespace AgentIssuesRequest { + export const method = "tabby/agent/issues"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType0(method); +} + +/** + * [Tabby] Issue Detail Request(↩️) + * + * This method is sent from the client to the server to check the detail of an issue. + * - method: `tabby/agent/issue/detail` + * - params: {@link IssueDetailParams} + * - result: {@link IssueDetailResult} | null + */ +export namespace AgentIssueDetailRequest { + export const method = "tabby/agent/issue/detail"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType(method); +} + +export type IssueDetailParams = { + name: IssueName; + helpMessageFormat?: "markdown"; +}; + +export type IssueDetailResult = { + name: IssueName; + helpMessage?: string; +}; + +/** + * [Tabby] Read File Request(↪️) + * + * This method is sent from the server to the client to read the file contents. + * - method: `tabby/workspaceFileSystem/readFile` + * - params: {@link ReadFileParams} + * - result: {@link ReadFileResult} | null + */ +export namespace ReadFileRequest { + export const method = "tabby/workspaceFileSystem/readFile"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type ReadFileParams = { + uri: URI; + /** + * If `text` is select, the result should try to decode the file contents to string, + * otherwise, the result should be a raw binary array. + */ + format: "text"; + /** + * When omitted, read the whole file. + */ + range?: Range; +}; + +export type ReadFileResult = { + /** + * If `text` is select, the result should be a string. + */ + text?: string; +}; + +/** + * [Tabby] DataStore Get Request(↪️) + * + * This method is sent from the server to the client to get the value of the given key. + * - method: `tabby/dataStore/get` + * - params: {@link DataStoreGetParams} + * - result: any + */ +export namespace DataStoreGetRequest { + export const method = "tabby/dataStore/get"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type DataStoreGetParams = { + key: string; +}; + +/** + * [Tabby] DataStore Set Request(↪️) + * + * This method is sent from the server to the client to set the value of the given key. + * - method: `tabby/dataStore/set` + * - params: {@link DataStoreSetParams} + * - result: boolean + */ +export namespace DataStoreSetRequest { + export const method = "tabby/dataStore/set"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type DataStoreSetParams = { + key: string; + value: any; +}; + +/** + * [Tabby] Language Support Declaration Request(↪️) + * + * This method is sent from the server to the client to request the support from another language server. + * See LSP `textDocument/declaration`. + * - method: `tabby/languageSupport/textDocument/declaration` + * - params: {@link DeclarationParams} + * - result: {@link Declaration} | {@link LocationLink}[] | null + */ +export namespace LanguageSupportDeclarationRequest { + export const method = "tabby/languageSupport/textDocument/declaration"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType< + DeclarationParams, + Declaration | LocationLink[] | null, + never, + void, + void + >(method); +} + +/** + * [Tabby] Semantic Tokens Range Request(↪️) + * + * This method is sent from the server to the client to request the support from another language server. + * See LSP `textDocument/semanticTokens/range`. + * - method: `tabby/languageSupport/textDocument/semanticTokens/range` + * - params: {@link SemanticTokensRangeParams} + * - result: {@link SemanticTokensRangeResult} | null + */ +export namespace LanguageSupportSemanticTokensRangeRequest { + export const method = "tabby/languageSupport/textDocument/semanticTokens/range"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType< + SemanticTokensRangeParams, + SemanticTokensRangeResult | null, + never, + void, + void + >(method); +} + +export type SemanticTokensRangeResult = { + legend: SemanticTokensLegend; + tokens: SemanticTokens; +}; + +/** + * [Tabby] Git Repository Request(↪️) + * + * This method is sent from the server to the client to get the git repository state of a file. + * - method: `tabby/git/repository` + * - params: {@link GitRepositoryParams} + * - result: {@link GitRepository} | null + */ +export namespace GitRepositoryRequest { + export const method = "tabby/git/repository"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type GitRepositoryParams = { + /** + * The URI of the file to get the git repository state of. + */ + uri: URI; +}; + +export type GitRepository = { + /** + * The root URI of the git repository. + */ + root: URI; + /** + * The url of the default remote. + */ + remoteUrl?: string; + /** + * List of remotes in the git repository. + */ + remotes?: { + name: string; + url: string; + }[]; +}; + +/** + * [Tabby] Git Diff Request(↪️) + * + * This method is sent from the server to the client to get the diff of a git repository. + * - method: `tabby/git/diff` + * - params: {@link GitDiffParams} + * - result: {@link GitDiffResult} | null + */ +export namespace GitDiffRequest { + export const method = "tabby/git/diff"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type GitDiffParams = { + /** + * The root URI of the git repository. + */ + repository: URI; + /** + * Returns the cached or uncached diff of the git repository. + */ + cached: boolean; +}; + +export type GitDiffResult = { + /** + * The diff of the git repository. + * - It could be the full diff. + * - It could be a list of diff for each single file, sorted by the priority. + * This will be useful when the full diff is too large, and we will select + * from the split diffs to generate a prompt under the tokens limit. + */ + diff: string | string[]; +}; + +/** + * [Tabby] Editor Options Request(↪️) + * + * This method is sent from the server to the client to get the diff of a git repository. + * - method: `tabby/editorOptions` + * - params: {@link EditorOptionsParams} + * - result: {@link EditorOptions} | null + */ +export namespace EditorOptionsRequest { + export const method = "tabby/editorOptions"; + export const messageDirection = MessageDirection.serverToClient; + export const type = new ProtocolRequestType(method); +} + +export type EditorOptionsParams = { + /** + * The uri of the document for which the editor options are requested. + */ + uri: URI; +}; + +export type EditorOptions = { + /** + * A string representing the indentation for the editor. It could be 2 or 4 spaces, or 1 tab. + */ + indentation: string; +}; diff --git a/clients/tabby-agent/src/utils/range.ts b/clients/tabby-agent/src/utils/range.ts new file mode 100644 index 000000000000..e00143e76e54 --- /dev/null +++ b/clients/tabby-agent/src/utils/range.ts @@ -0,0 +1,63 @@ +import { Position, Range } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +export function isPositionEqual(a: Position, b: Position): boolean { + return a.line === b.line && a.character === b.character; +} +export function isPositionBefore(a: Position, b: Position): boolean { + return a.line < b.line || (a.line === b.line && a.character < b.character); +} +export function isPositionAfter(a: Position, b: Position): boolean { + return a.line > b.line || (a.line === b.line && a.character > b.character); +} +export function isPositionBeforeOrEqual(a: Position, b: Position): boolean { + return a.line < b.line || (a.line === b.line && a.character <= b.character); +} +export function isPositionAfterOrEqual(a: Position, b: Position): boolean { + return a.line > b.line || (a.line === b.line && a.character >= b.character); +} +export function isPositionInRange(a: Position, b: Range): boolean { + return isPositionBeforeOrEqual(b.start, a) && isPositionBeforeOrEqual(a, b.end); +} +export function isRangeEqual(a: Range, b: Range): boolean { + return isPositionEqual(a.start, b.start) && isPositionEqual(a.end, b.end); +} +export function isEmptyRange(a: Range): boolean { + return isPositionAfterOrEqual(a.start, a.end); +} +export function unionRange(a: Range, b: Range): Range { + return { + start: isPositionBefore(a.start, b.start) + ? { line: a.start.line, character: a.start.character } + : { line: b.start.line, character: b.start.character }, + end: isPositionAfter(a.end, b.end) + ? { line: a.end.line, character: a.end.character } + : { line: b.end.line, character: b.end.character }, + }; +} +export function intersectionRange(a: Range, b: Range): Range | null { + const range = { + start: isPositionAfter(a.start, b.start) + ? { line: a.start.line, character: a.start.character } + : { line: b.start.line, character: b.start.character }, + end: isPositionBefore(a.end, b.end) + ? { line: a.end.line, character: a.end.character } + : { line: b.end.line, character: b.end.character }, + }; + return isEmptyRange(range) ? null : range; +} +export function documentRange(doc: TextDocument): Range { + return { + start: { + line: 0, + character: 0, + }, + end: { + line: doc.lineCount, + character: 0, + }, + }; +} +export function rangeInDocument(a: Range, doc: TextDocument): Range | null { + return intersectionRange(a, documentRange(doc)); +} diff --git a/clients/tabby-agent/src/utils/string.ts b/clients/tabby-agent/src/utils/string.ts new file mode 100644 index 000000000000..95dcc5e01b33 --- /dev/null +++ b/clients/tabby-agent/src/utils/string.ts @@ -0,0 +1,71 @@ +// Keywords appear in the code everywhere, but we don't want to use them for +// matching in code searching. +// Just filter them out before we start using a syntax parser. +const reservedKeywords = [ + // Typescript: https://github.com/microsoft/TypeScript/issues/2536 + "as", + "any", + "boolean", + "break", + "case", + "catch", + "class", + "const", + "constructor", + "continue", + "debugger", + "declare", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "module", + "new", + "null", + "number", + "of", + "package", + "private", + "protected", + "public", + "require", + "return", + "set", + "static", + "string", + "super", + "switch", + "symbol", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]; +export function extractNonReservedWordList(text: string): string { + const re = /\w+/g; + return [ + ...new Set(text.match(re)?.filter((symbol) => symbol.length > 2 && !reservedKeywords.includes(symbol))).values(), + ].join(" "); +} diff --git a/clients/tabby-agent/tsconfig.json b/clients/tabby-agent/tsconfig.json index 930e87c9a09d..bd50a6858b44 100644 --- a/clients/tabby-agent/tsconfig.json +++ b/clients/tabby-agent/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "commonjs", "target": "ES2020", - "lib": ["ES2020", "dom"], + "lib": ["ES2020", "WebWorker"], "sourceMap": true, "esModuleInterop": true, "resolveJsonModule": true, @@ -15,5 +15,5 @@ "noUnusedParameters": true, "allowSyntheticDefaultImports": true }, - "include": ["./src", "./tests/**/*.test.ts"] + "include": ["./src"] } diff --git a/clients/tabby-agent/tsup.config.ts b/clients/tabby-agent/tsup.config.ts index 303b77c61450..3427b4578bfe 100644 --- a/clients/tabby-agent/tsup.config.ts +++ b/clients/tabby-agent/tsup.config.ts @@ -1,44 +1,15 @@ +import { defineConfig } from "tsup"; import path from "path"; import fs from "fs-extra"; -import type { BuildOptions, Plugin } from "esbuild"; -import { defineConfig } from "tsup"; -import { copy } from "esbuild-plugin-copy"; +import type { Plugin } from "esbuild"; import { polyfillNode } from "esbuild-plugin-polyfill-node"; -import { dependencies } from "./package.json"; import dedent from "dedent"; -function markSideEffects(value: boolean, packages: string[]): Plugin { +function processWinCa(copyRootsExe: boolean = false): Plugin { return { - name: "sideEffects", + name: "processWinCa", setup: (build) => { - build.onResolve({ filter: /. */ }, async (args) => { - if (args.pluginData || !packages.includes(args.path)) { - return; - } - const { path, ...rest } = args; - rest.pluginData = true; - const result = await build.resolve(path, rest); - result.sideEffects = value; - return result; - }); - }, - }; -} - -function defineEnvs(targetOptions: BuildOptions, envs: { browser: boolean }) { - targetOptions["define"] = { - ...targetOptions["define"], - "process.env.IS_TEST": "false", - "process.env.IS_BROWSER": Boolean(envs?.browser).toString(), - }; - return targetOptions; -} - -function handleWinCaNativeBinaries(): Plugin { - return { - name: "handleWinCaNativeBinaries", - setup: (build) => { - build.onLoad({ filter: /win-ca\/lib\/crypt32-\w*.node$/ }, async (args) => { + build.onLoad({ filter: /win-ca\/lib\/crypt32-\w*.node$/ }, async () => { // As win-ca fallback is used, skip not required `.node` binaries return { contents: "", @@ -46,104 +17,90 @@ function handleWinCaNativeBinaries(): Plugin { }; }); build.onLoad({ filter: /win-ca\/lib\/fallback.js$/ }, async (args) => { - // Copy `roots.exe` binary to `dist/win-ca`, and the LICENSE - const binaryName = "roots.exe"; - const winCaPackagePath = path.join(path.dirname(args.path), ".."); - const license = await fs.readFile(path.join(winCaPackagePath, "LICENSE")); - const packageJson = await fs.readJSON(path.join(winCaPackagePath, "package.json")); - const exePath = path.join(path.dirname(args.path), binaryName); - const outDir = path.join(build.initialOptions.outdir ?? "", "win-ca"); - build.onEnd(async () => { + if (copyRootsExe) { + // Copy `roots.exe` binary to `dist/win-ca`, and the LICENSE + const binaryName = "roots.exe"; + const winCaPackagePath = path.join(path.dirname(args.path), ".."); + const license = await fs.readFile(path.join(winCaPackagePath, "LICENSE")); + const packageJson = await fs.readJSON(path.join(winCaPackagePath, "package.json")); + const exePath = path.join(path.dirname(args.path), binaryName); + const outDir = path.join(build.initialOptions.outdir ?? "", "win-ca"); await fs.ensureDir(outDir); await fs.copyFile(exePath, path.join(outDir, binaryName)); await fs.writeFile( path.join(outDir, "LICENSE"), dedent` - win-ca v${packageJson.version} - ${packageJson.homepage} + win-ca v${packageJson.version} + ${packageJson.homepage} - ${license} + ${license} `, ); - }); - return {}; + return {}; + } }); }, }; } -export default async () => [ - defineConfig({ - name: "node-cjs", - entry: ["src/index.ts"], - platform: "node", - target: "node18", - format: ["cjs"], - sourcemap: true, - esbuildOptions(options) { - defineEnvs(options, { browser: false }); - }, - clean: true, - }), - defineConfig({ - name: "browser-esm", - entry: ["src/index.ts"], - platform: "browser", - format: ["esm"], - treeshake: "smallest", // To remove unused libraries in browser. - sourcemap: true, - esbuildPlugins: [ - polyfillNode({ - polyfills: { fs: true }, - }), - // Mark sideEffects false for tree-shaking unused libraries in browser. - markSideEffects(false, ["chokidar", "file-stream-rotator"]), - ], - esbuildOptions(options) { - defineEnvs(options, { browser: true }); - }, - clean: true, - }), - defineConfig({ - name: "type-defs", - entry: ["src/index.ts"], - dts: { - only: true, +const banner = dedent` + /** + * Tabby Agent + * https://github.com/tabbyml/tabby/tree/main/clients/tabby-agent + * Copyright (c) 2023-2024 TabbyML, Inc. + * Licensed under the Apache License 2.0. + */`; + +export default defineConfig(async () => { + return [ + { + name: "lsp-protocol", + entry: ["src/lsp/protocol.ts"], + dts: true, + banner: { + js: banner, + }, }, - clean: true, - }), - defineConfig({ - name: "cli", - entry: ["src/cli.ts"], - platform: "node", - target: "node18", - noExternal: Object.keys(dependencies), - treeshake: "smallest", - minify: true, - sourcemap: true, - banner: { - js: dedent` - /** - * Tabby Agent - * https://github.com/tabbyml/tabby/tree/main/clients/tabby-agent - * Copyright (c) 2023-2024 TabbyML, Inc. - * Licensed under the Apache License 2.0. - */`, + { + name: "lsp-node", + entry: ["src/lsp/index.ts"], + outDir: "dist/node", + platform: "node", + target: "node18", + sourcemap: true, + banner: { + js: banner, + }, + define: { + "process.env.IS_TEST": "false", + "process.env.IS_BROWSER": "false", + }, + esbuildPlugins: [processWinCa(true)], + clean: true, }, - esbuildPlugins: [ - copy({ - assets: [ - { - from: "./wasm/*", - to: "./wasm", - }, - ], - }), - handleWinCaNativeBinaries(), - ], - esbuildOptions(options) { - defineEnvs(options, { browser: false }); + { + name: "lsp-browser", + entry: ["src/lsp/index.ts"], + outDir: "dist/browser", + platform: "browser", + format: "esm", + treeshake: "smallest", // Required for browser to cleanup fs related libs + sourcemap: true, + banner: { + js: banner, + }, + external: ["glob", "fs-extra", "chokidar", "file-stream-rotator", "win-ca", "mac-ca"], + define: { + "process.env.IS_TEST": "false", + "process.env.IS_BROWSER": "true", + }, + esbuildPlugins: [ + processWinCa(), + polyfillNode({ + polyfills: {}, + }), + ], + clean: true, }, - clean: true, - }), -]; + ]; +}); diff --git a/clients/vscode/.eslintrc b/clients/vscode/.eslintrc index 2348094dbc6a..fc269b94bf9d 100644 --- a/clients/vscode/.eslintrc +++ b/clients/vscode/.eslintrc @@ -6,8 +6,10 @@ }, "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], - "rules": { - "@typescript-eslint/no-explicit-any": 0 - } + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/strict", + "plugin:@typescript-eslint/stylistic", + "prettier" + ] } diff --git a/clients/vscode/.vscode/launch.json b/clients/vscode/.vscode/launch.json index dd83eec4912b..6068498f70c8 100644 --- a/clients/vscode/.vscode/launch.json +++ b/clients/vscode/.vscode/launch.json @@ -13,11 +13,24 @@ "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}" }, + { + "name": "Run Web Extension ", + "type": "extensionHost", + "debugWebWorkerHost": true, + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--disable-extensions", + "--extensionDevelopmentKind=web" + ], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + }, { "name": "Run Web Extension in Browser", "type": "node", "request": "launch", - "runtimeExecutable": "yarn", + "runtimeExecutable": "pnpm", "runtimeArgs": [ "vscode-test-web", "--extensionDevelopmentPath=${workspaceFolder}", diff --git a/clients/vscode/package.json b/clients/vscode/package.json index aad66286c55d..30e5f7693e63 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -7,7 +7,7 @@ "repository": "https://github.com/TabbyML/tabby", "bugs": "https://github.com/TabbyML/tabby/issues", "license": "Apache-2.0", - "version": "1.6.0", + "version": "1.7.0-dev", "keywords": [ "ai", "autocomplete", @@ -32,7 +32,7 @@ "onView:chatView" ], "main": "./dist/node/extension.js", - "browser": "./dist/web/extension.js", + "browser": "./dist/browser/extension.js", "contributes": { "commands": [ { @@ -265,9 +265,9 @@ }, "scripts": { "build": "tsc --noEmit && tsup --minify --treeshake smallest", - "watch": "tsup --sourcemap --watch ./ --ignore-watch ./dist --ignore-watch ./.vscode-test-web --watch ../tabby-agent/dist", - "dev": "code --extensionDevelopmentPath=$PWD --disable-extensions && turbo watch", - "dev:browser": "vscode-test-web --extensionDevelopmentPath=$PWD --browserType=chromium --port=3000 && turbo watch", + "watch": "tsc-watch --noEmit --onSuccess \"tsup\"", + "dev": "code --extensionDevelopmentPath=$PWD --disable-extensions && pnpm watch", + "dev:browser": "vscode-test-web --extensionDevelopmentPath=$PWD --browserType=chromium --port=3000 && pnpm watch", "lint": "eslint --ext .ts ./src && prettier --check .", "lint:fix": "eslint --fix --ext .ts ./src && prettier --write .", "vscode:prepackage": "turbo build", @@ -278,6 +278,7 @@ "devDependencies": { "@types/mocha": "^10.0.1", "@types/node": "18.x", + "@types/object-hash": "^3.0.0", "@types/vscode": "^1.82.0", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", @@ -285,19 +286,22 @@ "@vscode/test-web": "^0.0.44", "@vscode/vsce": "^2.15.0", "assert": "^2.0.0", + "dedent": "^0.7.0", "esbuild-plugin-copy": "^2.1.1", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.55.0", "eslint-config-prettier": "^9.0.0", + "get-installed-path": "^4.0.8", "prettier": "^3.0.0", - "tsup": "^7.1.0", + "tsc-watch": "^6.2.0", + "tsup": "^8.0.2", "typescript": "^5.3.2" }, "dependencies": { - "@orama/orama": "^2.0.15", "@quilted/threads": "^2.2.0", - "@xstate/fsm": "^2.0.1", + "object-hash": "^3.0.0", "tabby-agent": "workspace:*", - "tabby-chat-panel": "workspace:*" + "tabby-chat-panel": "workspace:*", + "vscode-languageclient": "^9.0.1" } } diff --git a/clients/vscode/src/Commands.ts b/clients/vscode/src/Commands.ts new file mode 100644 index 000000000000..d44f8ae2edf4 --- /dev/null +++ b/clients/vscode/src/Commands.ts @@ -0,0 +1,307 @@ +import { + workspace, + window, + env, + commands, + ExtensionContext, + Uri, + Disposable, + InputBoxValidationSeverity, + ProgressLocation, + ThemeIcon, +} from "vscode"; +import os from "os"; +import path from "path"; +import { strict as assert } from "assert"; +import { Client } from "./lsp/Client"; +import { Config } from "./Config"; +import { InlineCompletionProvider } from "./InlineCompletionProvider"; +import { ChatViewProvider } from "./chat/ChatViewProvider"; +import { GitProvider, Repository } from "./git/GitProvider"; + +export class Commands { + constructor( + private readonly context: ExtensionContext, + private readonly client: Client, + private readonly config: Config, + private readonly inlineCompletionProvider: InlineCompletionProvider, + private readonly chatViewProvider: ChatViewProvider, + private readonly gitProvider: GitProvider, + ) { + const registrations = Object.keys(this.commands).map((key) => { + const commandName = `tabby.${key}`; + const commandHandler = this.commands[key]; + if (commandHandler) { + return commands.registerCommand(commandName, commandHandler, this); + } + return null; + }); + const notNullRegistrations = registrations.filter((disposable): disposable is Disposable => disposable !== null); + this.context.subscriptions.push(...notNullRegistrations); + } + + commands: Record void> = { + applyCallback: (callback: (() => void) | undefined) => { + callback?.(); + }, + toggleInlineCompletionTriggerMode: (value: "automatic" | "manual" | undefined) => { + let target = value; + if (!target) { + if (this.config.inlineCompletionTriggerMode === "automatic") { + target = "manual"; + } else { + target = "automatic"; + } + } + this.config.inlineCompletionTriggerMode = target; + }, + setApiEndpoint: () => { + window + .showInputBox({ + prompt: "Enter the URL of your Tabby Server", + value: this.config.serverEndpoint, + validateInput: (input: string) => { + try { + const url = new URL(input); + assert(url.protocol == "http:" || url.protocol == "https:"); + } catch (error) { + return { + message: "Please enter a validate http or https URL.", + severity: InputBoxValidationSeverity.Error, + }; + } + return null; + }, + }) + .then((url) => { + if (url) { + this.config.serverEndpoint = url; + } + }); + }, + setApiToken: () => { + const currentToken = this.config.serverToken; + window + .showInputBox({ + prompt: "Enter your personal token", + value: currentToken.length > 0 ? currentToken : undefined, + password: true, + }) + .then((token) => { + if (token === undefined) { + return; // User canceled + } + this.config.serverToken = token; + }); + }, + openSettings: () => { + commands.executeCommand("workbench.action.openSettings", "@ext:TabbyML.vscode-tabby"); + }, + openTabbyAgentSettings: () => { + if (env.appHost !== "desktop") { + window.showWarningMessage("Tabby Agent config file is not supported in browser.", { modal: true }); + return; + } + const agentUserConfig = Uri.joinPath(Uri.file(os.homedir()), ".tabby-client", "agent", "config.toml"); + workspace.fs.stat(agentUserConfig).then( + () => { + workspace.openTextDocument(agentUserConfig).then((document) => { + window.showTextDocument(document); + }); + }, + () => { + window.showWarningMessage("Failed to open Tabby Agent config file.", { modal: true }); + }, + ); + }, + openOnlineHelp: () => { + window + .showQuickPick([ + { + label: "Online Documentation", + iconPath: new ThemeIcon("book"), + alwaysShow: true, + }, + { + label: "Model Registry", + description: "Explore more recommend models from Tabby's model registry", + iconPath: new ThemeIcon("library"), + alwaysShow: true, + }, + { + label: "Tabby Slack Community", + description: "Join Tabby's Slack community to get help or feed back", + iconPath: new ThemeIcon("comment-discussion"), + alwaysShow: true, + }, + { + label: "Tabby GitHub Repository", + description: "View the source code for Tabby, and open issues", + iconPath: new ThemeIcon("github"), + alwaysShow: true, + }, + ]) + .then((selection) => { + if (selection) { + switch (selection.label) { + case "Online Documentation": + env.openExternal(Uri.parse("https://tabby.tabbyml.com/")); + break; + case "Model Registry": + env.openExternal(Uri.parse("https://tabby.tabbyml.com/docs/models/")); + break; + case "Tabby Slack Community": + env.openExternal(Uri.parse("https://links.tabbyml.com/join-slack-extensions/")); + break; + case "Tabby GitHub Repository": + env.openExternal(Uri.parse("https://github.com/tabbyml/tabby")); + break; + } + } + }); + }, + openKeybindings: () => { + commands.executeCommand("workbench.action.openGlobalKeybindings", "tabby.inlineCompletion"); + }, + gettingStarted: () => { + commands.executeCommand("workbench.action.openWalkthrough", "TabbyML.vscode-tabby#gettingStarted"); + }, + "inlineCompletion.trigger": () => { + commands.executeCommand("editor.action.inlineSuggest.trigger"); + }, + "inlineCompletion.accept": () => { + commands.executeCommand("editor.action.inlineSuggest.commit"); + }, + "inlineCompletion.acceptNextWord": () => { + this.inlineCompletionProvider.handleEvent("accept_word"); + commands.executeCommand("editor.action.inlineSuggest.acceptNextWord"); + }, + "inlineCompletion.acceptNextLine": () => { + this.inlineCompletionProvider.handleEvent("accept_line"); + // FIXME: this command move cursor to next line, but we want to move cursor to the end of current line + commands.executeCommand("editor.action.inlineSuggest.acceptNextLine"); + }, + "inlineCompletion.dismiss": () => { + this.inlineCompletionProvider.handleEvent("dismiss"); + commands.executeCommand("editor.action.inlineSuggest.hide"); + }, + "notifications.mute": (type: string) => { + const notifications = this.config.mutedNotifications; + if (!notifications.includes(type)) { + const updated = notifications.concat(type); + this.config.mutedNotifications = updated; + } + }, + "notifications.resetMuted": (type?: string) => { + const notifications = this.config.mutedNotifications; + if (type) { + const updated = notifications.filter((item) => item !== type); + this.config.mutedNotifications = updated; + } else { + this.config.mutedNotifications = []; + } + }, + "experimental.chat.explainCodeBlock": async () => { + const alignIndent = (text: string) => { + const lines = text.split("\n"); + const subsequentLines = lines.slice(1); + + // Determine the minimum indent for subsequent lines + const minIndent = subsequentLines.reduce((min, line) => { + const match = line.match(/^(\s*)/); + const indent = match ? match[0].length : 0; + return line.trim() ? Math.min(min, indent) : min; + }, Infinity); + + // Remove the minimum indent + const adjustedLines = lines.slice(1).map((line) => line.slice(minIndent)); + + return [lines[0]?.trim(), ...adjustedLines].join("\n"); + }; + + const editor = window.activeTextEditor; + if (editor) { + const text = editor.document.getText(editor.selection); + const workspaceFolder = workspace.workspaceFolders?.[0]?.uri.fsPath || ""; + + commands.executeCommand("tabby.chatView.focus"); + + const filePath = editor.document.fileName.replace(workspaceFolder, ""); + this.chatViewProvider.sendMessage({ + message: "Explain the selected code:", + selectContext: { + kind: "file", + content: alignIndent(text), + range: { + start: editor.selection.start.line + 1, + end: editor.selection.end.line + 1, + }, + filepath: filePath.startsWith("/") ? filePath.substring(1) : filePath, + git_url: "https://github.com/tabbyML/tabby", // FIXME + }, + }); + } else { + window.showInformationMessage("No active editor"); + } + }, + "experimental.chat.generateCommitMessage": async () => { + const repos = this.gitProvider.getRepositories() ?? []; + if (repos.length < 1) { + window.showInformationMessage("No Git repositories found."); + return; + } + // Select repo + let selectedRepo: Repository | undefined = undefined; + if (repos.length == 1) { + selectedRepo = repos[0]; + } else { + const selected = await window.showQuickPick( + repos + .map((repo) => { + const repoRoot = repo.rootUri.fsPath; + return { + label: path.basename(repoRoot), + detail: repoRoot, + iconPath: new ThemeIcon("repo"), + picked: repo.ui.selected, + alwaysShow: true, + value: repo, + }; + }) + .sort((a, b) => { + if (a.detail.startsWith(b.detail)) { + return 1; + } else if (b.detail.startsWith(a.detail)) { + return -1; + } else { + return a.label.localeCompare(b.label); + } + }), + { placeHolder: "Select a Git repository" }, + ); + selectedRepo = selected?.value; + } + if (!selectedRepo) { + return; + } + window.withProgress( + { + location: ProgressLocation.Notification, + title: "Generating commit message...", + cancellable: true, + }, + async (_, token) => { + // Focus on scm view + commands.executeCommand("workbench.view.scm"); + const result = await this.client.chat.generateCommitMessage( + { repository: selectedRepo.rootUri.toString() }, + token, + ); + if (result) { + selectedRepo.inputBox.value = result.commitMessage; + } + }, + ); + }, + }; +} diff --git a/clients/vscode/src/Config.ts b/clients/vscode/src/Config.ts new file mode 100644 index 000000000000..4b90fcfbece1 --- /dev/null +++ b/clients/vscode/src/Config.ts @@ -0,0 +1,125 @@ +import { EventEmitter } from "events"; +import { workspace, ExtensionContext, WorkspaceConfiguration, ConfigurationTarget, Memento } from "vscode"; +import { State as LanguageClientState } from "vscode-languageclient"; +import { ClientProvidedConfig, ServerConfig } from "tabby-agent"; +import { Client } from "./lsp/Client"; + +export class Config extends EventEmitter { + private serverConfig?: ServerConfig; + + constructor( + private readonly context: ExtensionContext, + private readonly client: Client, + ) { + super(); + this.client.languageClient.onDidChangeState(async (state) => { + if (state.newState === LanguageClientState.Running) { + this.serverConfig = await this.client.agent.fetchServerConfig(); + this.emit("updatedServerConfig"); + } + }); + this.client.agent.on("didChangeServerConfig", (params: ServerConfig) => { + this.serverConfig = params; + this.emit("updatedServerConfig"); + }); + context.subscriptions.push( + workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration("tabby")) { + this.emit("updated"); + } + }), + ); + } + + get workspace(): WorkspaceConfiguration { + return workspace.getConfiguration("tabby"); + } + + get memento(): Memento { + return this.context.globalState; + } + + get server(): ServerConfig { + return ( + this.serverConfig ?? { + endpoint: this.serverEndpoint, + token: this.serverToken, + requestHeaders: null, + } + ); + } + + get serverEndpoint(): string { + return this.workspace.get("api.endpoint", ""); + } + + set serverEndpoint(value: string) { + if (value !== this.serverEndpoint) { + this.workspace.update("api.endpoint", value, ConfigurationTarget.Global); + } + } + + get serverToken(): string { + return this.memento.get("server.token", ""); + } + + set serverToken(value: string) { + if (value !== this.serverToken) { + this.memento.update("server.token", value); + this.emit("updated"); + } + } + + get inlineCompletionTriggerMode(): "automatic" | "manual" { + return this.workspace.get("inlineCompletion.triggerMode", "automatic"); + } + + set inlineCompletionTriggerMode(value: "automatic" | "manual") { + if (value !== this.inlineCompletionTriggerMode) { + this.workspace.update("inlineCompletion.triggerMode", value, ConfigurationTarget.Global); + } + } + + get inlineCompletionEnabled(): boolean { + return workspace.getConfiguration("editor").get("inlineSuggest.enabled", true); + } + + set inlineCompletionEnabled(value: boolean) { + if (value !== this.inlineCompletionEnabled) { + workspace.getConfiguration("editor").update("inlineSuggest.enabled", value, ConfigurationTarget.Global); + } + } + + get keybindings(): "vscode-style" | "tabby-style" { + return this.workspace.get("keybindings", "vscode-style"); + } + + get anonymousUsageTrackingDisabled(): boolean { + return this.workspace.get("usage.anonymousUsageTracking", false); + } + + get mutedNotifications(): string[] { + return this.memento.get("notifications.muted", []); + } + + set mutedNotifications(value: string[]) { + this.memento.update("notifications.muted", value); + this.emit("updated"); + } + + buildClientProvidedConfig(): ClientProvidedConfig { + return { + server: { + endpoint: this.serverEndpoint, + token: this.serverToken, + }, + inlineCompletion: { + triggerMode: this.inlineCompletionTriggerMode == "automatic" ? "auto" : "manual", + }, + keybindings: this.keybindings == "tabby-style" ? "tabby-style" : "default", + anonymousUsageTracking: { + disable: this.anonymousUsageTrackingDisabled, + }, + }; + } +} diff --git a/clients/vscode/src/ContextVariables.ts b/clients/vscode/src/ContextVariables.ts new file mode 100644 index 000000000000..8c46fd35690c --- /dev/null +++ b/clients/vscode/src/ContextVariables.ts @@ -0,0 +1,56 @@ +import { commands } from "vscode"; +import { Client } from "./lsp/Client"; +import { Config } from "./Config"; + +export class ContextVariables { + private chatEnabledValue = false; + private explainCodeBlockEnabledValue = false; + private generateCommitMessageEnabledValue = false; + + constructor( + private readonly client: Client, + private readonly config: Config, + ) { + this.chatEnabled = this.client.chat.isAvailable; + this.client.chat.on("didChangeAvailability", (params: boolean) => { + this.chatEnabled = params; + }); + this.updateExperimentalFlags(); + this.config.on("updated", () => { + this.updateExperimentalFlags(); + }); + } + + private updateExperimentalFlags() { + const experimental = this.config.workspace.get>("experimental", {}); + this.explainCodeBlockEnabled = !!experimental["chat.explainCodeBlock"]; + this.generateCommitMessageEnabled = !!experimental["chat.generateCommitMessage"]; + } + + get chatEnabled(): boolean { + return this.chatEnabledValue; + } + + private set chatEnabled(value: boolean) { + commands.executeCommand("setContext", "tabby.chatEnabled", value); + this.chatEnabledValue = value; + } + + get explainCodeBlockEnabled(): boolean { + return this.explainCodeBlockEnabledValue; + } + + private set explainCodeBlockEnabled(value: boolean) { + commands.executeCommand("setContext", "tabby.explainCodeBlockEnabled", value); + this.explainCodeBlockEnabledValue = value; + } + + get generateCommitMessageEnabled(): boolean { + return this.generateCommitMessageEnabledValue; + } + + private set generateCommitMessageEnabled(value: boolean) { + commands.executeCommand("setContext", "tabby.generateCommitMessageEnabled", value); + this.generateCommitMessageEnabledValue = value; + } +} diff --git a/clients/vscode/src/InlineCompletionProvider.ts b/clients/vscode/src/InlineCompletionProvider.ts new file mode 100644 index 000000000000..eefd6bce1e6a --- /dev/null +++ b/clients/vscode/src/InlineCompletionProvider.ts @@ -0,0 +1,213 @@ +import { + InlineCompletionItemProvider, + InlineCompletionContext, + InlineCompletionTriggerKind, + InlineCompletionItem, + TextDocument, + Position, + CancellationToken, + SnippetString, + Range, + window, +} from "vscode"; +import { InlineCompletionParams } from "vscode-languageclient"; +import { InlineCompletionRequest, InlineCompletionList, EventParams } from "tabby-agent"; +import { EventEmitter } from "events"; +import { getLogger } from "./logger"; +import { Client } from "./lsp/Client"; +import { Config } from "./Config"; + +interface DisplayedCompletion { + id: string; + displayedAt: number; + completions: InlineCompletionList; + index: number; +} + +export class InlineCompletionProvider extends EventEmitter implements InlineCompletionItemProvider { + private readonly logger = getLogger(); + private displayedCompletion: DisplayedCompletion | null = null; + private ongoing: Promise | null = null; + private triggerMode: "automatic" | "manual"; + + constructor( + private readonly client: Client, + private readonly config: Config, + ) { + super(); + this.triggerMode = this.config.inlineCompletionTriggerMode; + this.config.on("updated", () => { + this.triggerMode = this.config.inlineCompletionTriggerMode; + }); + } + + get isLoading(): boolean { + return this.ongoing !== null; + } + + async provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken, + ): Promise { + this.logger.debug("Function provideInlineCompletionItems called."); + + if (this.displayedCompletion) { + // auto dismiss by new completion + this.handleEvent("dismiss"); + } + + if (context.triggerKind === InlineCompletionTriggerKind.Automatic && this.triggerMode === "manual") { + this.logger.debug("Skip automatic trigger when triggerMode is manual."); + return null; + } + + // Skip when trigger automatically and text selected + if ( + context.triggerKind === InlineCompletionTriggerKind.Automatic && + window.activeTextEditor && + !window.activeTextEditor.selection.isEmpty + ) { + this.logger.debug("Text selected, skipping."); + return null; + } + + // Check if autocomplete widget is visible + if (context.selectedCompletionInfo !== undefined) { + this.logger.debug("Autocomplete widget is visible, skipping."); + return null; + } + + if (token.isCancellationRequested) { + this.logger.debug("Completion request is canceled before send request."); + return null; + } + + const params: InlineCompletionParams = { + context, + textDocument: { + uri: document.uri.toString(), + }, + position: { + line: position.line, + character: position.character, + }, + }; + let request: Promise | undefined = undefined; + try { + request = this.client.languageClient.sendRequest(InlineCompletionRequest.method, params, token); + this.ongoing = request; + this.emit("didChangeLoading", true); + const result = await this.ongoing; + this.ongoing = null; + this.emit("didChangeLoading", false); + + if (token.isCancellationRequested) { + return null; + } + if (!result || result.items.length === 0) { + return null; + } + + this.handleEvent("show", result); + + return result.items.map((item, index) => { + return new InlineCompletionItem( + typeof item.insertText === "string" ? item.insertText : new SnippetString(item.insertText.value), + item.range + ? new Range( + item.range.start.line, + item.range.start.character, + item.range.end.line, + item.range.end.character, + ) + : undefined, + { + title: "", + command: "tabby.applyCallback", + arguments: [ + () => { + this.handleEvent("accept", result, index); + }, + ], + }, + ); + }); + } catch (error) { + if (this.ongoing === request) { + // the request was not replaced by a new request + this.ongoing = null; + this.emit("didChangeLoading", false); + } + return null; + } + } + + // FIXME: We don't listen to the user cycling through the items, + // so we don't know the 'index' (except for the 'accept' event). + // For now, just use the first item to report other events. + async handleEvent( + event: "show" | "accept" | "dismiss" | "accept_word" | "accept_line", + completions?: InlineCompletionList, + index = 0, + ) { + if (event === "show" && completions) { + const item = completions.items[index]; + const cmplId = item?.data?.eventId?.completionId.replace("cmpl-", ""); + const timestamp = Date.now(); + this.displayedCompletion = { + id: `view-${cmplId}-at-${timestamp}`, + completions, + index, + displayedAt: timestamp, + }; + await this.postEvent(event, this.displayedCompletion); + } else if (this.displayedCompletion) { + this.displayedCompletion.index = index; + await this.postEvent(event, this.displayedCompletion); + this.displayedCompletion = null; + } + } + + private async postEvent( + event: "show" | "accept" | "dismiss" | "accept_word" | "accept_line", + displayedCompletion: DisplayedCompletion, + ) { + const { id, completions, index, displayedAt } = displayedCompletion; + const eventId = completions.items[index]?.data?.eventId; + if (!eventId) { + return; + } + const elapsed = Date.now() - displayedAt; + let eventData: { type: "view" | "select" | "dismiss"; selectKind?: "line"; elapsed?: number }; + switch (event) { + case "show": + eventData = { type: "view" }; + break; + case "accept": + eventData = { type: "select", elapsed }; + break; + case "dismiss": + eventData = { type: "dismiss", elapsed }; + break; + case "accept_word": + // select_kind should be "word" but not supported by Tabby Server yet, use "line" instead + eventData = { type: "select", selectKind: "line", elapsed }; + break; + case "accept_line": + eventData = { type: "select", selectKind: "line", elapsed }; + break; + default: + // unknown event type, should be unreachable + return; + } + const params: EventParams = { + ...eventData, + eventId, + viewId: id, + }; + // await not required + this.client.telemetry.postEvent(params); + } +} diff --git a/clients/vscode/src/Issues.ts b/clients/vscode/src/Issues.ts new file mode 100644 index 000000000000..3e3422511962 --- /dev/null +++ b/clients/vscode/src/Issues.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from "events"; +import { commands, window } from "vscode"; +import { IssueName, IssueDetailResult as IssueDetail } from "tabby-agent"; +import { Client } from "./lsp/Client"; +import { Config } from "./Config"; + +export class Issues extends EventEmitter { + private issues: IssueName[] = []; + + constructor( + private readonly client: Client, + private readonly config: Config, + ) { + super(); + // schedule initial fetch + this.client.agent.fetchIssues().then((params) => { + this.issues = params.issues; + this.emit("updated"); + }); + this.client.agent.on("didUpdateIssues", (params: IssueName[]) => { + this.issues = params; + this.emit("updated"); + }); + } + + get filteredIssues(): IssueName[] { + return this.issues.filter((item) => !this.config.mutedNotifications.includes(item)); + } + + get first(): IssueName | undefined { + return this.filteredIssues[0]; + } + + get current(): IssueName[] { + return this.filteredIssues; + } + + get length(): number { + return this.filteredIssues.length; + } + + async fetchDetail(issue: IssueName): Promise { + return await this.client.agent.fetchIssueDetail({ name: issue, helpMessageFormat: "markdown" }); + } + + async showHelpMessage(issue?: IssueName | undefined, modal = false) { + const name = issue ?? this.first; + if (!name) { + return; + } + if (name === "connectionFailed") { + if (modal) { + const detail = await this.client.agent.fetchIssueDetail({ + name: "connectionFailed", + helpMessageFormat: "markdown", + }); + window + .showWarningMessage( + `Cannot connect to Tabby Server.`, + { + modal: true, + detail: detail.helpMessage, + }, + "Settings", + "Online Help...", + ) + .then((selection) => { + switch (selection) { + case "Online Help...": + commands.executeCommand("tabby.openOnlineHelp"); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } else { + window.showWarningMessage(`Cannot connect to Tabby Server.`, "Detail", "Settings").then((selection) => { + switch (selection) { + case "Detail": + this.showHelpMessage(name, true); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } + } + if (name === "highCompletionTimeoutRate") { + if (modal) { + const detail = await this.client.agent.fetchIssueDetail({ + name: "connectionFailed", + helpMessageFormat: "markdown", + }); + window + .showWarningMessage( + "Most completion requests timed out.", + { + modal: true, + detail: detail.helpMessage, + }, + "Online Help...", + "Don't Show Again", + ) + .then((selection) => { + switch (selection) { + case "Online Help...": + commands.executeCommand("tabby.openOnlineHelp"); + break; + case "Don't Show Again": + commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); + break; + } + }); + } else { + window + .showWarningMessage("Most completion requests timed out.", "Detail", "Settings", "Don't Show Again") + .then((selection) => { + switch (selection) { + case "Detail": + this.showHelpMessage(name, true); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + case "Don't Show Again": + commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); + break; + } + }); + } + } + if (name === "slowCompletionResponseTime") { + if (modal) { + const detail = await this.client.agent.fetchIssueDetail({ + name: "slowCompletionResponseTime", + helpMessageFormat: "markdown", + }); + window + .showWarningMessage( + "Completion requests appear to take too much time.", + { + modal: true, + detail: detail.helpMessage, + }, + "Online Help...", + "Don't Show Again", + ) + .then((selection) => { + switch (selection) { + case "Online Help...": + commands.executeCommand("tabby.openOnlineHelp"); + break; + case "Don't Show Again": + commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); + break; + } + }); + } else { + window + .showWarningMessage( + "Completion requests appear to take too much time.", + "Detail", + "Settings", + "Don't Show Again", + ) + .then((selection) => { + switch (selection) { + case "Detail": + this.showHelpMessage(name, true); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + case "Don't Show Again": + commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); + break; + } + }); + } + } + } +} diff --git a/clients/vscode/src/StatusBarItem.ts b/clients/vscode/src/StatusBarItem.ts new file mode 100644 index 000000000000..b5484a14e1f5 --- /dev/null +++ b/clients/vscode/src/StatusBarItem.ts @@ -0,0 +1,328 @@ +import { commands, window, ExtensionContext, StatusBarAlignment, ThemeColor } from "vscode"; +import { State as LanguageClientState } from "vscode-languageclient"; +import { Client } from "./lsp/Client"; +import { Config } from "./Config"; +import { Issues } from "./Issues"; +import { InlineCompletionProvider } from "./InlineCompletionProvider"; + +const label = "Tabby"; +const iconAutomatic = "$(check)"; +const iconManual = "$(chevron-right)"; +const iconDisabled = "$(x)"; +const iconLoading = "$(loading~spin)"; +const iconDisconnected = "$(plug)"; +const iconUnauthorized = "$(key)"; +const iconIssueExist = "$(warning)"; +const colorNormal = new ThemeColor("statusBar.foreground"); +const colorWarning = new ThemeColor("statusBarItem.warningForeground"); +const backgroundColorNormal = new ThemeColor("statusBar.background"); +const backgroundColorWarning = new ThemeColor("statusBarItem.warningBackground"); + +export class StatusBarItem { + private item = window.createStatusBarItem(StatusBarAlignment.Right); + private status: + | "initializing" + | "automatic" + | "manual" + | "disabled" + | "loading" + | "disconnected" + | "unauthorized" + | "issueExist" = "initializing"; + + constructor( + private readonly context: ExtensionContext, + private readonly client: Client, + private readonly config: Config, + private readonly issues: Issues, + private readonly inlineCompletionProvider: InlineCompletionProvider, + ) { + this.updateStatus(); + this.item.show(); + this.context.subscriptions.push(this.item); + + this.client.languageClient.onDidChangeState(() => this.updateStatus()); + this.client.agent.on("didChangeStatus", () => this.updateStatus()); + this.issues.on("updated", () => this.updateStatus()); + this.inlineCompletionProvider.on("didChangeLoading", () => this.updateStatus()); + this.config.on("updated", () => this.updateStatus()); + } + + updateStatus(): void { + const languageClientState = this.client.languageClient.state; + if (languageClientState === LanguageClientState.Stopped) { + return; + } + if (languageClientState === LanguageClientState.Starting) { + return this.toInitializing(); + } + // languageClientState === LanguageClientState.Running + + const agentStatus = this.client.agent.status; + if (agentStatus === "finalized") { + return; + } + if (agentStatus === "notInitialized") { + return this.toInitializing(); + } + if (agentStatus === "disconnected") { + return this.toDisconnected(); + } + if (agentStatus === "unauthorized") { + return this.toUnauthorized(); + } + /// agentStatus === "ready" + + if (this.issues.length > 0) { + return this.toIssuesExist(); + } + if (this.inlineCompletionProvider.isLoading) { + return this.toLoading(); + } + if (!this.config.inlineCompletionEnabled) { + return this.toDisabled(); + } + const triggerMode = this.config.inlineCompletionTriggerMode; + if (triggerMode === "automatic") { + return this.toAutomatic(); + } + if (triggerMode === "manual") { + return this.toManual(); + } + } + + private toInitializing() { + if (this.status === "initializing") { + return; + } + this.status = "initializing"; + this.item.color = colorNormal; + this.item.backgroundColor = backgroundColorNormal; + this.item.text = `${iconLoading} ${label}`; + this.item.tooltip = "Tabby is initializing."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenInitializing()], + }; + } + + private toAutomatic() { + if (this.status === "automatic") { + return; + } + this.status = "automatic"; + this.item.color = colorNormal; + this.item.backgroundColor = backgroundColorNormal; + this.item.text = `${iconAutomatic} ${label}`; + this.item.tooltip = "Tabby automatic code completion is enabled."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenAutomaticTrigger()], + }; + } + + private toManual() { + if (this.status === "manual") { + return; + } + this.status = "manual"; + this.item.color = colorNormal; + this.item.backgroundColor = backgroundColorNormal; + this.item.text = `${iconManual} ${label}`; + this.item.tooltip = "Tabby is standing by, click or press `Alt + \\` to trigger code completion."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenManualTrigger()], + }; + } + + private toDisabled() { + if (this.status === "disabled") { + return; + } + this.status = "disabled"; + this.item.color = colorWarning; + this.item.backgroundColor = backgroundColorWarning; + this.item.text = `${iconDisabled} ${label}`; + this.item.tooltip = "Tabby is disabled. Click to check settings."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenInlineSuggestDisabled()], + }; + this.showInformationWhenInlineSuggestDisabled(); + } + + private toLoading() { + if (this.status === "loading") { + return; + } + this.status = "loading"; + this.item.color = colorNormal; + this.item.backgroundColor = backgroundColorNormal; + this.item.text = `${iconLoading} ${label}`; + this.item.tooltip = "Tabby is generating code completions."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenManualTriggerLoading()], + }; + } + + private toDisconnected() { + if (this.status === "disconnected") { + return; + } + this.status = "disconnected"; + this.item.color = colorWarning; + this.item.backgroundColor = backgroundColorWarning; + this.item.text = `${iconDisconnected} ${label}`; + this.item.tooltip = "Cannot connect to Tabby Server. Click to open settings."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.issues.showHelpMessage("connectionFailed")], + }; + } + + private toUnauthorized() { + if (this.status === "unauthorized") { + return; + } + this.status = "unauthorized"; + this.item.color = colorWarning; + this.item.backgroundColor = backgroundColorWarning; + this.item.text = `${iconUnauthorized} ${label}`; + this.item.tooltip = "Tabby Server requires authorization. Please set your personal token."; + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.showInformationWhenUnauthorized()], + }; + this.showInformationWhenUnauthorized(); + } + + private toIssuesExist() { + if (this.status === "issueExist") { + return; + } + this.status = "issueExist"; + this.item.color = colorWarning; + this.item.backgroundColor = backgroundColorWarning; + this.item.text = `${iconIssueExist} ${label}`; + switch (this.issues.first) { + case "highCompletionTimeoutRate": + this.item.tooltip = "Most completion requests timed out."; + break; + case "slowCompletionResponseTime": + this.item.tooltip = "Completion requests appear to take too much time."; + break; + default: + this.item.tooltip = ""; + break; + } + this.item.command = { + title: "", + command: "tabby.applyCallback", + arguments: [() => this.issues.showHelpMessage()], + }; + } + + private showInformationWhenInitializing() { + window.showInformationMessage("Tabby is initializing.", "Settings").then((selection) => { + switch (selection) { + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } + + private showInformationWhenAutomaticTrigger() { + window + .showInformationMessage( + "Tabby automatic code completion is enabled. Switch to manual trigger mode?", + "Manual Mode", + "Settings", + ) + .then((selection) => { + switch (selection) { + case "Manual Mode": + commands.executeCommand("tabby.toggleInlineCompletionTriggerMode", "manual"); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } + + private showInformationWhenManualTrigger() { + window + .showInformationMessage( + "Tabby is standing by. Trigger code completion manually?", + "Trigger", + "Automatic Mode", + "Settings", + ) + .then((selection) => { + switch (selection) { + case "Trigger": + commands.executeCommand("editor.action.inlineSuggest.trigger"); + break; + case "Automatic Mode": + commands.executeCommand("tabby.toggleInlineCompletionTriggerMode", "automatic"); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } + + private showInformationWhenManualTriggerLoading() { + window.showInformationMessage("Tabby is generating code completions.", "Settings").then((selection) => { + switch (selection) { + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } + + private showInformationWhenInlineSuggestDisabled() { + window + .showWarningMessage( + "Tabby's suggestion is not showing because inline suggestion is disabled. Please enable it first.", + "Enable", + "Settings", + ) + .then((selection) => { + switch (selection) { + case "Enable": + this.config.inlineCompletionEnabled = true; + break; + case "Settings": + commands.executeCommand("workbench.action.openSettings", "@id:editor.inlineSuggest.enabled"); + break; + } + }); + } + + private showInformationWhenUnauthorized() { + const message = "Tabby server requires authentication, please set your personal token."; + window.showWarningMessage(message, "Set Credentials", "Settings").then((selection) => { + switch (selection) { + case "Set Credentials": + commands.executeCommand("tabby.setApiToken"); + break; + case "Settings": + commands.executeCommand("tabby.openSettings"); + break; + } + }); + } +} diff --git a/clients/vscode/src/TabbyCompletionProvider.ts b/clients/vscode/src/TabbyCompletionProvider.ts deleted file mode 100644 index 4a30d4109c51..000000000000 --- a/clients/vscode/src/TabbyCompletionProvider.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { - CancellationToken, - InlineCompletionContext, - InlineCompletionItem, - InlineCompletionItemProvider, - InlineCompletionTriggerKind, - Position, - Range, - LocationLink, - TextDocument, - NotebookDocument, - NotebookRange, - Uri, - commands, - window, - workspace, -} from "vscode"; -import { EventEmitter } from "events"; -import { CompletionRequest, CompletionResponse, LogEventRequest } from "tabby-agent"; -import { gitApi } from "./gitApi"; -import { getLogger } from "./logger"; -import { agent } from "./agent"; -import { RecentlyChangedCodeSearch } from "./RecentlyChangedCodeSearch"; -import { extractSemanticSymbols, extractNonReservedWordList } from "./utils"; - -type DisplayedCompletion = { - id: string; - completions: CompletionResponse; - index: number; - displayedAt: number; -}; - -export class TabbyCompletionProvider extends EventEmitter implements InlineCompletionItemProvider { - private readonly logger = getLogger(); - private triggerMode: "automatic" | "manual" | "disabled" = "automatic"; - private onGoingRequestAbortController: AbortController | null = null; - private loading: boolean = false; - private displayedCompletion: DisplayedCompletion | null = null; - - recentlyChangedCodeSearch: RecentlyChangedCodeSearch | null = null; - - public constructor() { - super(); - this.updateConfiguration(); - workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("tabby") || event.affectsConfiguration("editor.inlineSuggest")) { - this.updateConfiguration(); - } - }); - this.logger.info("Created completion provider."); - } - - public getTriggerMode(): "automatic" | "manual" | "disabled" { - return this.triggerMode; - } - - public isLoading(): boolean { - return this.loading; - } - - public async provideInlineCompletionItems( - document: TextDocument, - position: Position, - context: InlineCompletionContext, - token: CancellationToken, - ): Promise { - this.logger.debug("Function provideInlineCompletionItems called."); - - if (this.displayedCompletion) { - // auto dismiss by new completion - this.handleEvent("dismiss"); - } - - if (context.triggerKind === InlineCompletionTriggerKind.Automatic && this.triggerMode === "manual") { - this.logger.debug("Skip automatic trigger when triggerMode is manual."); - return null; - } - - // Skip when trigger automatically and text selected - if ( - context.triggerKind === InlineCompletionTriggerKind.Automatic && - window.activeTextEditor && - !window.activeTextEditor.selection.isEmpty - ) { - this.logger.debug("Text selected, skipping."); - return null; - } - - // Check if autocomplete widget is visible - if (context.selectedCompletionInfo !== undefined) { - this.logger.debug("Autocomplete widget is visible, skipping."); - return null; - } - - if (token?.isCancellationRequested) { - this.logger.debug("Completion request is canceled before agent request."); - return null; - } - - const additionalContext = this.buildAdditionalContext(document); - - const request: CompletionRequest = { - filepath: document.uri.toString(), - language: document.languageId, // https://code.visualstudio.com/docs/languages/identifiers - text: additionalContext.prefix + document.getText() + additionalContext.suffix, - position: additionalContext.prefix.length + document.offsetAt(position), - indentation: this.getEditorIndentation(), - manually: context.triggerKind === InlineCompletionTriggerKind.Invoke, - workspace: workspace.getWorkspaceFolder(document.uri)?.uri.toString(), - git: this.getGitContext(document.uri), - declarations: await this.collectDeclarationSnippets(document, position), - relevantSnippetsFromChangedFiles: await this.collectSnippetsFromRecentlyChangedFiles(document, position), - }; - - const abortController = new AbortController(); - this.onGoingRequestAbortController = abortController; - token?.onCancellationRequested(() => { - this.logger.debug("Completion request is canceled."); - abortController.abort(); - }); - - try { - this.loading = true; - this.emit("loadingStatusUpdated"); - const result = await agent().provideCompletions(request, { signal: abortController.signal }); - this.loading = false; - this.emit("loadingStatusUpdated"); - - if (token?.isCancellationRequested) { - this.logger.debug("Completion request is canceled after agent request."); - return null; - } - - if (result.items.length > 0) { - this.handleEvent("show", result); - - return result.items.map((item, index) => { - return new InlineCompletionItem( - item.insertText, - new Range( - document.positionAt(item.range.start - additionalContext.prefix.length), - document.positionAt(item.range.end - additionalContext.prefix.length), - ), - { - title: "", - command: "tabby.applyCallback", - arguments: [ - () => { - this.handleEvent("accept", result, index); - }, - ], - }, - ); - }); - } - } catch (error: any) { - if (this.onGoingRequestAbortController === abortController) { - // the request was not replaced by a new request, set loading to false safely - this.loading = false; - this.emit("loadingStatusUpdated"); - } - } - - return null; - } - - // FIXME: We don't listen to the user cycling through the items, - // so we don't know the 'index' (except for the 'accept' event). - // For now, just use the first item to report other events. - public handleEvent( - event: "show" | "accept" | "dismiss" | "accept_word" | "accept_line", - completions?: CompletionResponse, - index: number = 0, - ) { - if (event === "show" && completions) { - const item = completions.items[index]; - const cmplId = item?.data?.eventId?.completionId.replace("cmpl-", ""); - const timestamp = Date.now(); - this.displayedCompletion = { - id: `view-${cmplId}-at-${timestamp}`, - completions, - index, - displayedAt: timestamp, - }; - this.postEvent(event, this.displayedCompletion); - } else if (this.displayedCompletion) { - this.displayedCompletion.index = index; - this.postEvent(event, this.displayedCompletion); - this.displayedCompletion = null; - } - } - - private postEvent( - event: "show" | "accept" | "dismiss" | "accept_word" | "accept_line", - displayedCompletion: DisplayedCompletion, - ) { - const { id, completions, index, displayedAt } = displayedCompletion; - const elapsed = Date.now() - displayedAt; - let eventData: { type: string; select_kind?: "line"; elapsed?: number }; - switch (event) { - case "show": - eventData = { type: "view" }; - break; - case "accept": - eventData = { type: "select", elapsed }; - break; - case "dismiss": - eventData = { type: "dismiss", elapsed }; - break; - case "accept_word": - // select_kind should be "word" but not supported by Tabby Server yet, use "line" instead - eventData = { type: "select", select_kind: "line", elapsed }; - break; - case "accept_line": - eventData = { type: "select", select_kind: "line", elapsed }; - break; - default: - // unknown event type, should be unreachable - return; - } - try { - const eventId = completions.items[index]!.data!.eventId!; - const postBody: LogEventRequest = { - ...eventData, - completion_id: eventId.completionId, - choice_index: eventId.choiceIndex, - view_id: id, - }; - agent().postEvent(postBody); - } catch (error) { - // ignore error - } - } - - private getEditorIndentation(): string | undefined { - const editor = window.activeTextEditor; - if (!editor) { - return undefined; - } - - const { insertSpaces, tabSize } = editor.options; - if (insertSpaces && typeof tabSize === "number" && tabSize > 0) { - return " ".repeat(tabSize); - } else if (!insertSpaces) { - return "\t"; - } - return undefined; - } - - private updateConfiguration() { - if (!workspace.getConfiguration("editor").get("inlineSuggest.enabled", true)) { - this.triggerMode = "disabled"; - this.emit("triggerModeUpdated"); - } else { - this.triggerMode = workspace.getConfiguration("tabby").get("inlineCompletion.triggerMode", "automatic"); - this.emit("triggerModeUpdated"); - } - } - - private buildAdditionalContext(document: TextDocument): { prefix: string; suffix: string } { - if ( - document.uri.scheme === "vscode-notebook-cell" && - window.activeNotebookEditor?.notebook.uri.path === document.uri.path - ) { - // Add all the cells in the notebook as context - const notebook = window.activeNotebookEditor.notebook; - const current = window.activeNotebookEditor.selection.start; - const prefix = this.buildNotebookContext(notebook, new NotebookRange(0, current), document.languageId) + "\n\n"; - const suffix = - "\n\n" + - this.buildNotebookContext(notebook, new NotebookRange(current + 1, notebook.cellCount), document.languageId); - return { prefix, suffix }; - } - return { prefix: "", suffix: "" }; - } - - private notebookLanguageComments: { [languageId: string]: (code: string) => string } = { - markdown: (code) => "```\n" + code + "\n```", - python: (code) => - code - .split("\n") - .map((l) => "# " + l) - .join("\n"), - }; - - private buildNotebookContext(notebook: NotebookDocument, range: NotebookRange, languageId: string): string { - return notebook - .getCells(range) - .map((cell) => { - if (cell.document.languageId === languageId) { - return cell.document.getText(); - } else if (Object.keys(this.notebookLanguageComments).includes(languageId)) { - return this.notebookLanguageComments[languageId]!(cell.document.getText()); - } else { - return ""; - } - }) - .join("\n\n"); - } - - private getGitContext(uri: Uri): CompletionRequest["git"] | undefined { - this.logger.debug("Fetching git context..."); - const repo = gitApi?.getRepository(uri); - if (!repo) { - this.logger.debug("No git repo available."); - return undefined; - } - const context = { - root: repo.rootUri.toString(), - remotes: repo.state.remotes - .map((remote) => ({ - name: remote.name, - url: remote.fetchUrl ?? remote.pushUrl ?? "", - })) - .filter((remote) => { - return remote.url.length > 0; - }), - }; - this.logger.debug("Completed fetching git context."); - this.logger.trace("Git context:", context); - return context; - } - - private async collectDeclarationSnippets( - document: TextDocument, - position: Position, - ): Promise<{ filepath: string; offset: number; text: string }[] | undefined> { - const config = agent().getConfig().completion.prompt; - if (!config.fillDeclarations.enabled) { - return undefined; - } - this.logger.debug("Collecting declaration snippets..."); - this.logger.trace("Collecting snippets for:", { document: document.uri.toString(), position }); - const snippets: { filepath: string; offset: number; text: string }[] = []; - const snippetLocations: LocationLink[] = []; - // Find symbol positions in the previous lines - const prefixRange = new Range( - Math.max(0, position.line - config.maxPrefixLines + 1), - 0, - position.line, - position.character, - ); - const allowedSymbolTypes = [ - "class", - "decorator", - "enum", - "function", - "interface", - "macro", - "method", - "namespace", - "struct", - "type", - "typeParameter", - ]; - const allSymbols = await extractSemanticSymbols(document, prefixRange); - if (!allSymbols) { - this.logger.debug("Stop collecting declaration early due to symbols provider not available."); - return undefined; - } - const symbols = allSymbols.filter((symbol) => allowedSymbolTypes.includes(symbol.type)); - this.logger.trace("Found symbols in prefix text:", { symbols }); - // Loop through the symbol positions backwards - for (let symbolIndex = symbols.length - 1; symbolIndex >= 0; symbolIndex--) { - if (snippets.length >= config.fillDeclarations.maxSnippets) { - // Stop collecting snippets if the max number of snippets is reached - break; - } - const symbolPosition = symbols[symbolIndex]!.position; - const declarationLinks = await commands.executeCommand( - "vscode.executeDefinitionProvider", - document.uri, - symbolPosition, - ); - if ( - Array.isArray(declarationLinks) && - declarationLinks.length > 0 && - "targetUri" in declarationLinks[0] && - "targetRange" in declarationLinks[0] - ) { - const declarationLink = declarationLinks[0] as LocationLink; - this.logger.trace("Processing declaration link...", { - path: declarationLink.targetUri.toString(), - range: declarationLink.targetRange, - }); - if ( - declarationLink.targetUri.toString() == document.uri.toString() && - prefixRange.contains(declarationLink.targetRange.start) - ) { - // this symbol's declaration is already contained in the prefix range - // this also includes the case of the symbol's declaration is at this position itself - this.logger.trace("Skipping snippet as it is contained in the prefix."); - continue; - } - if ( - snippetLocations.find( - (link) => - link.targetUri.toString() == declarationLink.targetUri.toString() && - link.targetRange.isEqual(declarationLink.targetRange), - ) - ) { - this.logger.trace("Skipping snippet as it is already collected."); - continue; - } - const fileText = new TextDecoder().decode(await workspace.fs.readFile(declarationLink.targetUri)).split("\n"); - const offsetText = fileText.slice(0, declarationLink.targetRange.start.line).join("\n"); - const offset = offsetText.length; - let text = fileText - .slice(declarationLink.targetRange.start.line, declarationLink.targetRange.end.line + 1) - .join("\n"); - if (text.length > config.fillDeclarations.maxCharsPerSnippet) { - // crop the text to fit within the chars limit - text = text.slice(0, config.fillDeclarations.maxCharsPerSnippet); - text = text.slice(0, text.lastIndexOf("\n") + 1); - } - if (text.length > 0) { - this.logger.trace("Collected declaration snippet:", { text }); - snippets.push({ filepath: declarationLink.targetUri.toString(), offset, text }); - snippetLocations.push(declarationLink); - } - } - } - this.logger.debug("Completed collecting declaration snippets."); - this.logger.trace("Collected snippets:", snippets); - return snippets; - } - - private async collectSnippetsFromRecentlyChangedFiles( - document: TextDocument, - position: Position, - ): Promise<{ filepath: string; offset: number; text: string; score: number }[] | undefined> { - const config = agent().getConfig().completion.prompt; - if (!config.collectSnippetsFromRecentChangedFiles.enabled || !this.recentlyChangedCodeSearch) { - return undefined; - } - this.logger.debug("Collecting snippets from recently changed files..."); - this.logger.trace("Collecting snippets for:", { document: document.uri.toString(), position }); - const prefixRange = new Range( - Math.max(0, position.line - config.maxPrefixLines + 1), - 0, - position.line, - position.character, - ); - const prefixText = document.getText(prefixRange); - const query = extractNonReservedWordList(prefixText); - const snippets = await this.recentlyChangedCodeSearch.collectRelevantSnippets( - query, - document, - config.collectSnippetsFromRecentChangedFiles.maxSnippets, - ); - this.logger.debug("Completed collecting snippets from recently changed files."); - this.logger.trace("Collected snippets:", snippets); - return snippets; - } -} diff --git a/clients/vscode/src/TabbyStatusBarItem.ts b/clients/vscode/src/TabbyStatusBarItem.ts deleted file mode 100644 index 30729d7d1d27..000000000000 --- a/clients/vscode/src/TabbyStatusBarItem.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { StatusBarAlignment, ThemeColor, ExtensionContext, window } from "vscode"; -import { createMachine, interpret } from "@xstate/fsm"; -import type { StatusChangedEvent, AgentIssue } from "tabby-agent"; -import { getLogger } from "./logger"; -import { agent } from "./agent"; -import { notifications } from "./notifications"; -import { TabbyCompletionProvider } from "./TabbyCompletionProvider"; - -const label = "Tabby"; -const iconLoading = "$(loading~spin)"; -const iconAutomatic = "$(check)"; -const iconManual = "$(chevron-right)"; -const iconDisabled = "$(x)"; -const iconDisconnected = "$(plug)"; -const iconUnauthorized = "$(key)"; -const iconIssueExist = "$(warning)"; -const colorNormal = new ThemeColor("statusBar.foreground"); -const colorWarning = new ThemeColor("statusBarItem.warningForeground"); -const backgroundColorNormal = new ThemeColor("statusBar.background"); -const backgroundColorWarning = new ThemeColor("statusBarItem.warningBackground"); - -export class TabbyStatusBarItem { - private readonly logger = getLogger(); - private item = window.createStatusBarItem(StatusBarAlignment.Right); - private extensionContext: ExtensionContext; - private completionProvider: TabbyCompletionProvider; - - private subStatusForReady = [ - { - target: "issuesExist", - cond: () => { - let issues: AgentIssue["name"][] = agent().getIssues(); - if ( - this.extensionContext.globalState - .get("notifications.muted", []) - .includes("completionResponseTimeIssues") - ) { - issues = issues.filter( - (issue) => issue !== "highCompletionTimeoutRate" && issue !== "slowCompletionResponseTime", - ); - } - return issues.length > 0; - }, - }, - { - target: "automatic", - cond: () => this.completionProvider.getTriggerMode() === "automatic" && !this.completionProvider.isLoading(), - }, - { - target: "manual", - cond: () => this.completionProvider.getTriggerMode() === "manual" && !this.completionProvider.isLoading(), - }, - { - target: "loading", - cond: () => this.completionProvider.isLoading(), - }, - { - target: "disabled", - cond: () => this.completionProvider.getTriggerMode() === "disabled", - }, - ]; - - private fsm = createMachine({ - id: "statusBarItem", - initial: "initializing", - states: { - initializing: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toInitializing(), - }, - automatic: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toAutomatic(), - }, - manual: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toManual(), - }, - loading: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toLoading(), - }, - disabled: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toDisabled(), - }, - disconnected: { - on: { - ready: this.subStatusForReady, - unauthorized: "unauthorized", - }, - entry: () => this.toDisconnected(), - }, - unauthorized: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - }, - entry: () => this.toUnauthorized(), - }, - issuesExist: { - on: { - ready: this.subStatusForReady, - disconnected: "disconnected", - unauthorized: "unauthorized", - }, - entry: () => this.toIssuesExist(), - }, - }, - }); - - private fsmService = interpret(this.fsm); - - constructor(context: ExtensionContext, completionProvider: TabbyCompletionProvider) { - this.extensionContext = context; - this.completionProvider = completionProvider; - this.fsmService.start(); - this.fsmService.send(agent().getStatus()); - this.item.show(); - - this.completionProvider.on("triggerModeUpdated", () => { - this.refresh(); - }); - this.completionProvider.on("loadingStatusUpdated", () => { - this.refresh(); - }); - - agent().on("statusChanged", (event: StatusChangedEvent) => { - this.fsmService.send(event.status); - }); - - agent().on("authRequired", () => { - notifications.showInformationWhenUnauthorized(); - }); - - agent().on("issuesUpdated", () => { - const status = agent().getStatus(); - this.fsmService.send(status); - }); - } - - public register() { - return this.item; - } - - public refresh() { - this.fsmService.send(agent().getStatus()); - } - - private toInitializing() { - this.item.color = colorNormal; - this.item.backgroundColor = backgroundColorNormal; - this.item.text = `${iconLoading} ${label}`; - this.item.tooltip = "Tabby is initializing."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenInitializing()], - }; - } - - private toAutomatic() { - this.item.color = colorNormal; - this.item.backgroundColor = backgroundColorNormal; - this.item.text = `${iconAutomatic} ${label}`; - this.item.tooltip = "Tabby automatic code completion is enabled."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenAutomaticTrigger()], - }; - } - - private toManual() { - this.item.color = colorNormal; - this.item.backgroundColor = backgroundColorNormal; - this.item.text = `${iconManual} ${label}`; - this.item.tooltip = "Tabby is standing by, click or press `Alt + \\` to trigger code completion."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenManualTrigger()], - }; - } - - private toLoading() { - this.item.color = colorNormal; - this.item.backgroundColor = backgroundColorNormal; - this.item.text = `${iconLoading} ${label}`; - this.item.tooltip = "Tabby is generating code completions."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenManualTriggerLoading()], - }; - } - - private toDisabled() { - this.item.color = colorWarning; - this.item.backgroundColor = backgroundColorWarning; - this.item.text = `${iconDisabled} ${label}`; - this.item.tooltip = "Tabby is disabled. Click to check settings."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenInlineSuggestDisabled()], - }; - - this.logger.info("Tabby code completion is enabled but inline suggest is disabled."); - notifications.showInformationWhenInlineSuggestDisabled(); - } - - private toDisconnected() { - this.item.color = colorWarning; - this.item.backgroundColor = backgroundColorWarning; - this.item.text = `${iconDisconnected} ${label}`; - this.item.tooltip = "Cannot connect to Tabby Server. Click to open settings."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenDisconnected()], - }; - } - - private toUnauthorized() { - this.item.color = colorWarning; - this.item.backgroundColor = backgroundColorWarning; - this.item.text = `${iconUnauthorized} ${label}`; - this.item.tooltip = "Tabby Server requires authorization. Please set your personal token."; - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [() => notifications.showInformationWhenUnauthorized()], - }; - } - - private toIssuesExist() { - this.item.color = colorWarning; - this.item.backgroundColor = backgroundColorWarning; - this.item.text = `${iconIssueExist} ${label}`; - const issue = - agent().getIssueDetail({ name: "highCompletionTimeoutRate" }) ?? - agent().getIssueDetail({ name: "slowCompletionResponseTime" }); - switch (issue?.name) { - case "highCompletionTimeoutRate": - this.item.tooltip = "Most completion requests timed out."; - break; - case "slowCompletionResponseTime": - this.item.tooltip = "Completion requests appear to take too much time."; - break; - default: - this.item.tooltip = ""; - break; - } - this.item.command = { - title: "", - command: "tabby.applyCallback", - arguments: [ - () => { - switch (issue?.name) { - case "highCompletionTimeoutRate": - notifications.showInformationWhenHighCompletionTimeoutRate(); - break; - case "slowCompletionResponseTime": - notifications.showInformationWhenSlowCompletionResponseTime(); - break; - } - }, - ], - }; - } -} diff --git a/clients/vscode/src/agent.ts b/clients/vscode/src/agent.ts deleted file mode 100644 index e64ef2ffd207..000000000000 --- a/clients/vscode/src/agent.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ExtensionContext, workspace, env, version } from "vscode"; -import { TabbyAgent, AgentInitOptions, PartialAgentConfig, ClientProperties, DataStore } from "tabby-agent"; -import { getLogChannel } from "./logger"; - -function buildInitOptions(context: ExtensionContext): AgentInitOptions { - const configuration = workspace.getConfiguration("tabby"); - const config: PartialAgentConfig = {}; - const endpoint = configuration.get("api.endpoint"); - if (endpoint && endpoint.trim().length > 0) { - config.server = { - endpoint, - }; - } - const token = context.globalState.get("server.token"); - if (token && token.trim().length > 0) { - if (config.server) { - config.server.token = token; - } else { - config.server = { - token, - }; - } - } - const anonymousUsageTrackingDisabled = configuration.get("usage.anonymousUsageTracking", false); - if (anonymousUsageTrackingDisabled) { - config.anonymousUsageTracking = { - disable: true, - }; - } - const clientProperties: ClientProperties = { - user: { - vscode: { - triggerMode: configuration.get("inlineCompletion.triggerMode", "automatic"), - keybindings: configuration.get("keybindings", "vscode-style"), - }, - }, - session: { - client: `${env.appName} ${env.appHost} ${version}, ${context.extension.id} ${context.extension.packageJSON.version}`, - ide: { - name: `${env.appName} ${env.appHost}`, - version: version, - }, - tabby_plugin: { - name: context.extension.id, - version: context.extension.packageJSON.version, - }, - }, - }; - const extensionDataStore: DataStore = { - data: {}, - load: async function () { - this.data = context.globalState.get("data", {}); - }, - save: async function () { - context.globalState.update("data", this.data); - }, - }; - const dataStore = env.appHost === "desktop" ? undefined : extensionDataStore; - const loggers = [getLogChannel()]; - return { config, clientProperties, dataStore, loggers }; -} - -let instance: TabbyAgent | undefined = undefined; - -export function agent(): TabbyAgent { - if (!instance) { - throw new Error("Tabby Agent not initialized"); - } - return instance; -} - -export async function createAgentInstance(context: ExtensionContext): Promise { - if (!instance) { - const agent = new TabbyAgent(); - await agent.initialize(buildInitOptions(context)); - workspace.onDidChangeConfiguration(async (event) => { - const configuration = workspace.getConfiguration("tabby"); - if (event.affectsConfiguration("tabby.api.endpoint")) { - const endpoint = configuration.get("api.endpoint"); - if (endpoint && endpoint.trim().length > 0) { - agent.updateConfig("server.endpoint", endpoint); - } else { - agent.clearConfig("server.endpoint"); - } - } - if (event.affectsConfiguration("tabby.usage.anonymousUsageTracking")) { - const anonymousUsageTrackingDisabled = configuration.get("usage.anonymousUsageTracking", false); - if (anonymousUsageTrackingDisabled) { - agent.updateConfig("anonymousUsageTracking.disable", true); - } else { - agent.clearConfig("anonymousUsageTracking.disable"); - } - } - if (event.affectsConfiguration("tabby.inlineCompletion.triggerMode")) { - const triggerMode = configuration.get("inlineCompletion.triggerMode", "automatic"); - agent.updateClientProperties("user", "vscode.triggerMode", triggerMode); - } - if (event.affectsConfiguration("tabby.keybindings")) { - const keybindings = configuration.get("keybindings", "vscode-style"); - agent.updateClientProperties("user", "vscode.keybindings", keybindings); - } - }); - instance = agent; - } - return instance; -} - -export async function disposeAgentInstance(): Promise { - if (instance) { - await instance.finalize(); - instance = undefined; - } -} diff --git a/clients/vscode/src/ChatViewProvider.ts b/clients/vscode/src/chat/ChatViewProvider.ts similarity index 85% rename from clients/vscode/src/ChatViewProvider.ts rename to clients/vscode/src/chat/ChatViewProvider.ts index 8f1b8c579ea3..772ffe238cb0 100644 --- a/clients/vscode/src/ChatViewProvider.ts +++ b/clients/vscode/src/chat/ChatViewProvider.ts @@ -1,7 +1,8 @@ import { ExtensionContext, WebviewViewProvider, WebviewView, workspace, Uri, env } from "vscode"; -import { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; - -import { agent } from "./agent"; +import type { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; +import { ServerConfig } from "tabby-agent"; +import hashObject from "object-hash"; +import { Config } from "../Config"; import { createClient } from "./chatPanel"; // FIXME(wwayne): Example code has webview removed case, not sure when it would happen, need to double check @@ -10,18 +11,23 @@ export class ChatViewProvider implements WebviewViewProvider { client?: ServerApi; private pendingMessages: ChatMessage[] = []; - constructor(private readonly context: ExtensionContext) {} + constructor( + private readonly context: ExtensionContext, + private readonly config: Config, + ) {} public async resolveWebviewView(webviewView: WebviewView) { this.webview = webviewView; const extensionUri = this.context.extensionUri; - const { server } = agent().getConfig(); webviewView.webview.options = { enableScripts: true, localResourceRoots: [extensionUri], }; - webviewView.webview.html = await this._getWebviewContent(); + webviewView.webview.html = this._getWebviewContent(this.config.server); + this.config.on("updatedServerConfig", () => { + webviewView.webview.html = this._getWebviewContent(this.config.server); + }); this.client = createClient(webviewView, { navigate: async (context: Context) => { @@ -38,7 +44,7 @@ export class ChatViewProvider implements WebviewViewProvider { this.pendingMessages.forEach((message) => this.client?.sendMessage(message)); this.client?.init({ fetcherOptions: { - authorization: server.token, + authorization: this.config.server.token, }, }); } @@ -51,11 +57,11 @@ export class ChatViewProvider implements WebviewViewProvider { }); } - private async _getWebviewContent() { - const { server } = agent().getConfig(); + private _getWebviewContent(server: ServerConfig) { return ` + diff --git a/clients/vscode/src/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts similarity index 93% rename from clients/vscode/src/chatPanel.ts rename to clients/vscode/src/chat/chatPanel.ts index 3e81e5971a6b..9b3c9a1fed00 100644 --- a/clients/vscode/src/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -1,5 +1,5 @@ import { createThread, type ThreadOptions } from "@quilted/threads"; -import { ServerApi, ClientApi } from "tabby-chat-panel"; +import type { ServerApi, ClientApi } from "tabby-chat-panel"; import { WebviewView } from "vscode"; export function createThreadFromWebview, Target = Record>( diff --git a/clients/vscode/src/chat/dom.d.ts b/clients/vscode/src/chat/dom.d.ts new file mode 100644 index 000000000000..3fe0cf7633ad --- /dev/null +++ b/clients/vscode/src/chat/dom.d.ts @@ -0,0 +1,4 @@ +// We don't include DOM types in typescript building config + +// FIXME: This is required by `@quilted/threads` and `tabby-chat-panel` +declare type HTMLIFrameElement = unknown; diff --git a/clients/vscode/src/commands.ts b/clients/vscode/src/commands.ts deleted file mode 100644 index b54396cb7d3d..000000000000 --- a/clients/vscode/src/commands.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { - ConfigurationTarget, - InputBoxValidationSeverity, - ProgressLocation, - Uri, - ThemeIcon, - ExtensionContext, - workspace, - window, - env, - commands, -} from "vscode"; -import os from "os"; -import path from "path"; -import { strict as assert } from "assert"; -import { gitApi } from "./gitApi"; -import type { Repository } from "./types/git"; -import { agent } from "./agent"; -import { notifications } from "./notifications"; -import { TabbyCompletionProvider } from "./TabbyCompletionProvider"; -import { TabbyStatusBarItem } from "./TabbyStatusBarItem"; -import { ChatViewProvider } from "./ChatViewProvider"; - -const configTarget = ConfigurationTarget.Global; - -type Command = { - command: string; - callback: (...args: any[]) => any; - thisArg?: any; -}; - -const toggleInlineCompletionTriggerMode: Command = { - command: "tabby.toggleInlineCompletionTriggerMode", - callback: (value: "automatic" | "manual" | undefined) => { - const configuration = workspace.getConfiguration("tabby"); - let target = value; - if (!target) { - const current = configuration.get("inlineCompletion.triggerMode", "automatic"); - if (current === "automatic") { - target = "manual"; - } else { - target = "automatic"; - } - } - configuration.update("inlineCompletion.triggerMode", target, configTarget, false); - }, -}; - -const setApiEndpoint: Command = { - command: "tabby.setApiEndpoint", - callback: () => { - const configuration = workspace.getConfiguration("tabby"); - window - .showInputBox({ - prompt: "Enter the URL of your Tabby Server", - value: configuration.get("api.endpoint", ""), - validateInput: (input: string) => { - try { - const url = new URL(input); - assert(url.protocol == "http:" || url.protocol == "https:"); - } catch (_) { - return { - message: "Please enter a validate http or https URL.", - severity: InputBoxValidationSeverity.Error, - }; - } - return null; - }, - }) - .then((url) => { - if (url) { - configuration.update("api.endpoint", url, configTarget, false); - } - }); - }, -}; - -const setApiToken = (context: ExtensionContext): Command => { - return { - command: "tabby.setApiToken", - callback: () => { - const currentToken = agent().getConfig()["server"]["token"].trim(); - window - .showInputBox({ - prompt: "Enter your personal token", - value: currentToken.length > 0 ? currentToken : undefined, - password: true, - }) - .then((token) => { - if (token === undefined) { - return; // User canceled - } - if (token.length > 0) { - context.globalState.update("server.token", token); - agent().updateConfig("server.token", token); - } else { - context.globalState.update("server.token", undefined); - agent().clearConfig("server.token"); - } - }); - }, - }; -}; - -const openSettings: Command = { - command: "tabby.openSettings", - callback: () => { - commands.executeCommand("workbench.action.openSettings", "@ext:TabbyML.vscode-tabby"); - }, -}; - -const openTabbyAgentSettings: Command = { - command: "tabby.openTabbyAgentSettings", - callback: () => { - if (env.appHost !== "desktop") { - window.showWarningMessage("Tabby Agent config file is not supported on web.", { modal: true }); - return; - } - const agentUserConfig = Uri.joinPath(Uri.file(os.homedir()), ".tabby-client", "agent", "config.toml"); - workspace.fs.stat(agentUserConfig).then( - () => { - workspace.openTextDocument(agentUserConfig).then((document) => { - window.showTextDocument(document); - }); - }, - () => { - window.showWarningMessage("Tabby Agent config file not found.", { modal: true }); - }, - ); - }, -}; - -const openKeybindings: Command = { - command: "tabby.openKeybindings", - callback: () => { - commands.executeCommand("workbench.action.openGlobalKeybindings", "tabby.inlineCompletion"); - }, -}; - -const gettingStarted: Command = { - command: "tabby.gettingStarted", - callback: () => { - commands.executeCommand("workbench.action.openWalkthrough", "TabbyML.vscode-tabby#gettingStarted"); - }, -}; - -/** @deprecated Tabby Cloud auth */ -const openAuthPage: Command = { - command: "tabby.openAuthPage", - callback: (callbacks?: { onAuthStart?: () => void; onAuthEnd?: () => void }) => { - window.withProgress( - { - location: ProgressLocation.Notification, - title: "Tabby Server Authorization", - cancellable: true, - }, - async (progress, token) => { - const abortController = new AbortController(); - token.onCancellationRequested(() => { - abortController.abort(); - }); - const signal = abortController.signal; - try { - callbacks?.onAuthStart?.(); - progress.report({ message: "Generating authorization url..." }); - const authUrl = await agent().requestAuthUrl({ signal }); - if (authUrl) { - env.openExternal(Uri.parse(authUrl.authUrl)); - progress.report({ message: "Waiting for authorization from browser..." }); - await agent().waitForAuthToken(authUrl.code, { signal }); - assert(agent().getStatus() === "ready"); - notifications.showInformationAuthSuccess(); - } else if (agent().getStatus() === "ready") { - notifications.showInformationWhenStartAuthButAlreadyAuthorized(); - } else { - notifications.showInformationWhenAuthFailed(); - } - } catch (error: any) { - if (error.name === "AbortError") { - return; - } - notifications.showInformationWhenAuthFailed(); - } finally { - callbacks?.onAuthEnd?.(); - } - }, - ); - }, -}; - -const applyCallback: Command = { - command: "tabby.applyCallback", - callback: (callback) => { - callback?.(); - }, -}; - -const triggerInlineCompletion: Command = { - command: "tabby.inlineCompletion.trigger", - callback: () => { - commands.executeCommand("editor.action.inlineSuggest.trigger"); - }, -}; - -const acceptInlineCompletion: Command = { - command: "tabby.inlineCompletion.accept", - callback: () => { - commands.executeCommand("editor.action.inlineSuggest.commit"); - }, -}; - -const acceptInlineCompletionNextWord = (completionProvider: TabbyCompletionProvider): Command => { - return { - command: "tabby.inlineCompletion.acceptNextWord", - callback: () => { - completionProvider.handleEvent("accept_word"); - commands.executeCommand("editor.action.inlineSuggest.acceptNextWord"); - }, - }; -}; - -const acceptInlineCompletionNextLine = (completionProvider: TabbyCompletionProvider): Command => { - return { - command: "tabby.inlineCompletion.acceptNextLine", - callback: () => { - completionProvider.handleEvent("accept_line"); - // FIXME: this command move cursor to next line, but we want to move cursor to the end of current line - commands.executeCommand("editor.action.inlineSuggest.acceptNextLine"); - }, - }; -}; - -const dismissInlineCompletion = (completionProvider: TabbyCompletionProvider): Command => { - return { - command: "tabby.inlineCompletion.dismiss", - callback: () => { - completionProvider.handleEvent("dismiss"); - commands.executeCommand("editor.action.inlineSuggest.hide"); - }, - }; -}; - -const openOnlineHelp: Command = { - command: "tabby.openOnlineHelp", - callback: () => { - window - .showQuickPick([ - { - label: "Online Documentation", - iconPath: new ThemeIcon("book"), - alwaysShow: true, - }, - { - label: "Model Registry", - description: "Explore more recommend models from Tabby's model registry", - iconPath: new ThemeIcon("library"), - alwaysShow: true, - }, - { - label: "Tabby Slack Community", - description: "Join Tabby's Slack community to get help or feed back", - iconPath: new ThemeIcon("comment-discussion"), - alwaysShow: true, - }, - { - label: "Tabby GitHub Repository", - description: "View the source code for Tabby, and open issues", - iconPath: new ThemeIcon("github"), - alwaysShow: true, - }, - ]) - .then((selection) => { - if (selection) { - switch (selection.label) { - case "Online Documentation": - env.openExternal(Uri.parse("https://tabby.tabbyml.com/")); - break; - case "Model Registry": - env.openExternal(Uri.parse("https://tabby.tabbyml.com/docs/models/")); - break; - case "Tabby Slack Community": - env.openExternal(Uri.parse("https://links.tabbyml.com/join-slack-extensions/")); - break; - case "Tabby GitHub Repository": - env.openExternal(Uri.parse("https://github.com/tabbyml/tabby")); - break; - } - } - }); - }, -}; - -const muteNotifications = (context: ExtensionContext, statusBarItem: TabbyStatusBarItem): Command => { - return { - command: "tabby.notifications.mute", - callback: (type: string) => { - const notifications = context.globalState.get("notifications.muted", []); - notifications.push(type); - context.globalState.update("notifications.muted", notifications); - statusBarItem.refresh(); - }, - }; -}; - -const resetMutedNotifications = (context: ExtensionContext, statusBarItem: TabbyStatusBarItem): Command => { - return { - command: "tabby.notifications.resetMuted", - callback: (type?: string) => { - const notifications = context.globalState.get("notifications.muted", []); - if (type) { - context.globalState.update( - "notifications.muted", - notifications.filter((t) => t !== type), - ); - } else { - context.globalState.update("notifications.muted", []); - } - statusBarItem.refresh(); - }, - }; -}; - -const alignIndent = (text: string) => { - const lines = text.split("\n"); - const subsequentLines = lines.slice(1); - - // Determine the minimum indent for subsequent lines - const minIndent = subsequentLines.reduce((min, line) => { - const match = line.match(/^(\s*)/); - const indent = match ? match[0].length : 0; - return line.trim() ? Math.min(min, indent) : min; - }, Infinity); - - // Remove the minimum indent - const adjustedLines = lines.slice(1).map((line) => line.slice(minIndent)); - - return [lines[0]?.trim(), ...adjustedLines].join("\n"); -}; - -const explainCodeBlock = (chatViewProvider: ChatViewProvider): Command => { - return { - command: "tabby.experimental.chat.explainCodeBlock", - callback: async () => { - const editor = window.activeTextEditor; - if (editor) { - const text = editor.document.getText(editor.selection); - const workspaceFolder = workspace.workspaceFolders?.[0]?.uri.fsPath || ""; - - commands.executeCommand("tabby.chatView.focus"); - - const filePath = editor.document.fileName.replace(workspaceFolder, ""); - chatViewProvider.sendMessage({ - message: "Explain the selected code:", - selectContext: { - kind: "file", - content: alignIndent(text), - range: { - start: editor.selection.start.line + 1, - end: editor.selection.end.line + 1, - }, - filepath: filePath.startsWith("/") ? filePath.substring(1) : filePath, - git_url: "https://github.com/tabbyML/tabby", // FIXME - }, - }); - } else { - window.showInformationMessage("No active editor"); - } - }, - }; -}; - -const generateCommitMessage: Command = { - command: "tabby.experimental.chat.generateCommitMessage", - callback: async () => { - const repos = gitApi?.repositories ?? []; - if (repos.length < 1) { - window.showInformationMessage("No Git repositories found."); - return; - } - // Select repo - let selectedRepo: Repository | undefined = undefined; - if (repos.length == 1) { - selectedRepo = repos[0]; - } else { - const selected = await window.showQuickPick( - repos - .map((repo) => { - const repoRoot = repo.rootUri.fsPath; - return { - label: path.basename(repoRoot), - detail: repoRoot, - iconPath: new ThemeIcon("repo"), - picked: repo.ui.selected, - alwaysShow: true, - value: repo, - }; - }) - .sort((a, b) => { - if (a.detail.startsWith(b.detail)) { - return 1; - } else if (b.detail.startsWith(a.detail)) { - return -1; - } else { - return a.label.localeCompare(b.label); - } - }), - { placeHolder: "Select a Git repository" }, - ); - selectedRepo = selected?.value; - } - if (!selectedRepo) { - return; - } - const repo = selectedRepo; - // Get the diff - let diff = (await repo.diff(true)).trim(); - if (diff.length < 1) { - // if cached diff is empty, use uncached instead - diff = (await repo.diff(false)).trim(); - } - if (diff.length < 1) { - // uncached diff is still empty, return - return; - } - // Focus on scm view - commands.executeCommand("workbench.view.scm"); - window.withProgress( - { - location: ProgressLocation.Notification, - title: "Generating commit message...", - cancellable: true, - }, - async (_, token) => { - const abortController = new AbortController(); - token.onCancellationRequested(() => { - abortController.abort(); - }); - const signal = abortController.signal; - // Split diffs and sort by priority (modified timestamp) ascending - const diffsWithPriority = await Promise.all( - diff.split(/\n(?=diff)/).map(async (item) => { - let priority = Number.MAX_SAFE_INTEGER; - const filepath = /diff --git a\/.* b\/(.*)$/gm.exec(item)?.[1]; - if (filepath) { - const uri = Uri.joinPath(repo.rootUri, filepath); - priority = (await workspace.fs.stat(uri)).mtime; - } - return { diff: item, priority }; - }), - ); - const diffs = diffsWithPriority.sort((a, b) => a.priority - b.priority).map((item) => item.diff); - const message = await agent().generateCommitMessage(diffs, { signal }); - repo.inputBox.value = message; - }, - ); - }, -}; - -export const tabbyCommands = ( - context: ExtensionContext, - completionProvider: TabbyCompletionProvider, - statusBarItem: TabbyStatusBarItem, - chatViewProvider: ChatViewProvider, -) => - [ - toggleInlineCompletionTriggerMode, - setApiEndpoint, - setApiToken(context), - openSettings, - openTabbyAgentSettings, - openKeybindings, - gettingStarted, - openAuthPage, - applyCallback, - triggerInlineCompletion, - acceptInlineCompletion, - acceptInlineCompletionNextWord(completionProvider), - acceptInlineCompletionNextLine(completionProvider), - dismissInlineCompletion(completionProvider), - openOnlineHelp, - muteNotifications(context, statusBarItem), - resetMutedNotifications(context, statusBarItem), - explainCodeBlock(chatViewProvider), - generateCommitMessage, - ].map((command) => commands.registerCommand(command.command, command.callback, command.thisArg)); diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index 91e5a4df6a21..5f9656b04f69 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -1,86 +1,84 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below -import { ExtensionContext, commands, languages, workspace, window } from "vscode"; +import { window, ExtensionContext, Uri } from "vscode"; +import { LanguageClientOptions } from "vscode-languageclient"; +import { LanguageClient as NodeLanguageClient, ServerOptions, TransportKind } from "vscode-languageclient/node"; +import { LanguageClient as BrowserLanguageClient } from "vscode-languageclient/browser"; import { getLogger } from "./logger"; -import { createAgentInstance, disposeAgentInstance } from "./agent"; -import { tabbyCommands } from "./commands"; -import { TabbyCompletionProvider } from "./TabbyCompletionProvider"; -import { TabbyStatusBarItem } from "./TabbyStatusBarItem"; -import { RecentlyChangedCodeSearch } from "./RecentlyChangedCodeSearch"; -import { ChatViewProvider } from "./ChatViewProvider"; +import { Client } from "./lsp/Client"; +import { InlineCompletionProvider } from "./InlineCompletionProvider"; +import { Config } from "./Config"; +import { Issues } from "./Issues"; +import { GitProvider } from "./git/GitProvider"; +import { ContextVariables } from "./ContextVariables"; +import { StatusBarItem } from "./StatusBarItem"; +import { ChatViewProvider } from "./chat/ChatViewProvider"; +import { Commands } from "./Commands"; +const isBrowser = !!process.env["IS_BROWSER"]; const logger = getLogger(); +let client: Client | undefined = undefined; -// this method is called when your extension is activated -// your extension is activated the very first time the command is executed export async function activate(context: ExtensionContext) { logger.info("Activating Tabby extension..."); - const agent = await createAgentInstance(context); - const completionProvider = new TabbyCompletionProvider(); - context.subscriptions.push(languages.registerInlineCompletionItemProvider({ pattern: "**" }, completionProvider)); - - const collectSnippetsFromRecentChangedFilesConfig = - agent.getConfig().completion.prompt.collectSnippetsFromRecentChangedFiles; - if (collectSnippetsFromRecentChangedFilesConfig.enabled) { - const recentlyChangedCodeSnippetsIndex = new RecentlyChangedCodeSearch( - collectSnippetsFromRecentChangedFilesConfig.indexing, - ); - context.subscriptions.push( - workspace.onDidChangeTextDocument((event) => { - // Ensure that the changed file is belong to a workspace folder - const workspaceFolder = workspace.getWorkspaceFolder(event.document.uri); - if (workspaceFolder && workspace.workspaceFolders?.includes(workspaceFolder)) { - recentlyChangedCodeSnippetsIndex.handleDidChangeTextDocument(event); - } - }), - ); - completionProvider.recentlyChangedCodeSearch = recentlyChangedCodeSnippetsIndex; + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file" }, + { scheme: "untitled" }, + { scheme: "vscode-notebook-cell" }, + { scheme: "vscode-userdata" }, + ], + outputChannel: logger, + }; + if (isBrowser) { + const workerModulePath = Uri.joinPath(context.extensionUri, "dist/tabby-agent/browser/index.mjs"); + const worker = new Worker(workerModulePath.toString()); + const languageClient = new BrowserLanguageClient("Tabby", "Tabby", clientOptions, worker); + client = new Client(context, languageClient); + } else { + const serverModulePath = context.asAbsolutePath("dist/tabby-agent/node/index.js"); + const serverOptions: ServerOptions = { + run: { + module: serverModulePath, + transport: TransportKind.ipc, + }, + debug: { + module: serverModulePath, + transport: TransportKind.ipc, + }, + }; + const languageClient = new NodeLanguageClient("Tabby", serverOptions, clientOptions); + client = new Client(context, languageClient); } + const config = new Config(context, client); + const inlineCompletionProvider = new InlineCompletionProvider(client, config); + const gitProvider = new GitProvider(); + + client.registerConfigManager(config); + client.registerInlineCompletionProvider(inlineCompletionProvider); + client.registerGitProvider(gitProvider); + + await client.start(); - const statusBarItem = new TabbyStatusBarItem(context, completionProvider); - context.subscriptions.push(statusBarItem.register()); + const issues = new Issues(client, config); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ /* @ts-expect-error noUnusedLocals */ + const contextVariables = new ContextVariables(client, config); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ /* @ts-expect-error noUnusedLocals */ + const statusBarItem = new StatusBarItem(context, client, config, issues, inlineCompletionProvider); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ /* @ts-expect-error noUnusedLocals */ + const commands = new Commands(context, client, config, inlineCompletionProvider, gitProvider); // Register chat panel - const chatViewProvider = new ChatViewProvider(context); + const chatViewProvider = new ChatViewProvider(context, config); context.subscriptions.push( window.registerWebviewViewProvider("tabby.chatView", chatViewProvider, { webviewOptions: { retainContextWhenHidden: true }, // FIXME(wwayne): necessary? }), ); - context.subscriptions.push(...tabbyCommands(context, completionProvider, statusBarItem, chatViewProvider)); - - const updateIsChatEnabledContextVariable = () => { - if (agent.getStatus() === "ready") { - const healthState = agent.getServerHealthState(); - const isChatEnabled = Boolean(healthState?.chat_model); - commands.executeCommand("setContext", "tabby.chatEnabled", isChatEnabled); - } - }; - agent.on("statusChanged", () => { - updateIsChatEnabledContextVariable(); - }); - updateIsChatEnabledContextVariable(); - - const updateIsExplainCodeEnabledContextVariable = () => { - const experimental = workspace.getConfiguration("tabby").get>("experimental", {}); - const isExplainCodeEnabled = experimental["chat.explainCodeBlock"] || false; - commands.executeCommand("setContext", "tabby.explainCodeBlockEnabled", isExplainCodeEnabled); - const isGenerateCommitMessageEnabled = experimental["chat.generateCommitMessage"] || false; - commands.executeCommand("setContext", "tabby.generateCommitMessageEnabled", isGenerateCommitMessageEnabled); - }; - workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("tabby.experimental")) { - updateIsExplainCodeEnabledContextVariable(); - } - }); - updateIsExplainCodeEnabledContextVariable(); logger.info("Tabby extension activated."); } -// this method is called when your extension is deactivated export async function deactivate() { logger.info("Deactivating Tabby extension..."); - await disposeAgentInstance(); + await client?.stop(); logger.info("Tabby extension deactivated."); } diff --git a/clients/vscode/src/git/GitProvider.ts b/clients/vscode/src/git/GitProvider.ts new file mode 100644 index 000000000000..87194705bfd7 --- /dev/null +++ b/clients/vscode/src/git/GitProvider.ts @@ -0,0 +1,48 @@ +import { extensions, workspace, Extension, Uri } from "vscode"; +import type { GitExtension, Repository as GitRepository, API } from "./git"; +export type Repository = GitRepository; + +export class GitProvider { + private ext: Extension | undefined; + private api: API | undefined; + constructor() { + this.ext = extensions.getExtension("vscode.git"); + this.api = this.ext?.isActive ? this.ext.exports.getAPI(1) : undefined; + } + + getRepositories(): Repository[] | undefined { + return this.api?.repositories; + } + + getRepository(uri: Uri): Repository | undefined { + return this.api?.getRepository(uri) ?? undefined; + } + + async getDiff(repository: Repository, cached: boolean): Promise { + const diff = (await repository.diff(cached)).trim(); + const diffs = await Promise.all( + diff.split(/\n(?=diff)/).map(async (item: string) => { + let priority = Number.MAX_SAFE_INTEGER; + const filepath = /diff --git a\/.* b\/(.*)$/gm.exec(item)?.[1]; + if (filepath) { + const uri = Uri.joinPath(repository.rootUri, filepath); + try { + priority = (await workspace.fs.stat(uri)).mtime; + } catch (error) { + //ignore + } + } + return { diff: item, priority }; + }), + ); + return diffs.sort((a, b) => a.priority - b.priority).map((item) => item.diff); + } + + getDefaultRemoteUrl(repository: Repository): string | undefined { + const remote = + repository.state.remotes.find((remote) => remote.name === "origin") || + repository.state.remotes.find((remote) => remote.name === "upstream") || + repository.state.remotes[0]; + return remote?.fetchUrl ?? remote?.pushUrl; + } +} diff --git a/clients/vscode/src/types/git.d.ts b/clients/vscode/src/git/git.d.ts similarity index 100% rename from clients/vscode/src/types/git.d.ts rename to clients/vscode/src/git/git.d.ts diff --git a/clients/vscode/src/gitApi.ts b/clients/vscode/src/gitApi.ts deleted file mode 100644 index 0af27b37d2c5..000000000000 --- a/clients/vscode/src/gitApi.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { extensions } from "vscode"; -import { API } from "./types/git"; - -const gitExt = extensions.getExtension("vscode.git"); -export const gitApi: API = gitExt?.isActive ? gitExt.exports.getAPI(1) : undefined; diff --git a/clients/vscode/src/logger.ts b/clients/vscode/src/logger.ts index 36fba754e0e8..50842b862c98 100644 --- a/clients/vscode/src/logger.ts +++ b/clients/vscode/src/logger.ts @@ -1,35 +1,43 @@ import { window, LogOutputChannel } from "vscode"; -let instance: LogOutputChannel | undefined = undefined; +const outputChannel = window.createOutputChannel("Tabby", { log: true }); -export function getLogChannel(): LogOutputChannel { - if (!instance) { - instance = window.createOutputChannel("Tabby", { log: true }); - } - return instance; -} - -export function getLogger(tag: string = "Tabby"): LogOutputChannel { - const rawLogger = getLogChannel(); +export function getLogger(tag = "Tabby"): LogOutputChannel { const tagMessage = (message: string) => { return `[${tag}] ${message}`; }; - return { - ...rawLogger, - trace: (message: string, ...args: any[]) => { - rawLogger.trace(tagMessage(message), ...args); - }, - debug: (message: string, ...args: any[]) => { - rawLogger.debug(tagMessage(message), ...args); - }, - info: (message: string, ...args: any[]) => { - rawLogger.info(tagMessage(message), ...args); + return new Proxy(outputChannel, { + get(target, method) { + if (method === "trace") { + return (message: string, ...args: unknown[]) => { + target.trace(tagMessage(message), ...args); + }; + } + if (method === "debug") { + return (message: string, ...args: unknown[]) => { + target.debug(tagMessage(message), ...args); + }; + } + if (method === "info") { + return (message: string, ...args: unknown[]) => { + target.info(tagMessage(message), ...args); + }; + } + if (method === "warn") { + return (message: string, ...args: unknown[]) => { + target.warn(tagMessage(message), ...args); + }; + } + if (method === "error") { + return (message: string, ...args: unknown[]) => { + target.error(tagMessage(message), ...args); + }; + } + if (method in target) { + /* @ts-expect-error no-implicit-any */ + return target[method]; + } + return undefined; }, - warn: (message: string, ...args: any[]) => { - rawLogger.warn(tagMessage(message), ...args); - }, - error: (message: string, ...args: any[]) => { - rawLogger.error(tagMessage(message), ...args); - }, - }; + }); } diff --git a/clients/vscode/src/lsp/AgentFeature.ts b/clients/vscode/src/lsp/AgentFeature.ts new file mode 100644 index 000000000000..c498c0e3afa2 --- /dev/null +++ b/clients/vscode/src/lsp/AgentFeature.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from "events"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { + ClientCapabilities, + AgentServerConfigRequest, + AgentServerConfigSync, + ServerConfig, + AgentStatusRequest, + AgentStatusSync, + Status, + AgentIssuesRequest, + AgentIssuesSync, + IssueList, + AgentIssueDetailRequest, + IssueDetailParams, + IssueDetailResult, +} from "tabby-agent"; + +export class AgentFeature extends EventEmitter implements StaticFeature { + private disposables: Disposable[] = []; + private statusValue: Status = "notInitialized"; + + constructor(private readonly client: BaseLanguageClient) { + super(); + } + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.tabby = { + ...capabilities.tabby, + agent: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onNotification(AgentServerConfigSync.type, (params) => { + this.emit("didChangeServerConfig", params.server); + }), + ); + this.disposables.push( + this.client.onNotification(AgentStatusSync.type, (params) => { + this.statusValue = params.status; + this.emit("didChangeStatus", params.status); + }), + ); + this.disposables.push( + this.client.onNotification(AgentIssuesSync.type, (params) => { + this.emit("didUpdateIssues", params.issues); + }), + ); + // schedule a initial status sync + this.fetchStatus().then((status) => { + if (status !== this.statusValue) { + this.statusValue = status; + this.emit("didChangeStatus", status); + } + }); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } + + get status(): Status { + return this.statusValue; + } + + async fetchServerConfig(): Promise { + return this.client.sendRequest(AgentServerConfigRequest.type); + } + + async fetchStatus(): Promise { + return this.client.sendRequest(AgentStatusRequest.type); + } + + async fetchIssues(): Promise { + return this.client.sendRequest(AgentIssuesRequest.type); + } + + async fetchIssueDetail(params: IssueDetailParams): Promise { + return this.client.sendRequest(AgentIssueDetailRequest.method, params); + } +} diff --git a/clients/vscode/src/lsp/ChatFeature.ts b/clients/vscode/src/lsp/ChatFeature.ts new file mode 100644 index 000000000000..2aa7bf395bb8 --- /dev/null +++ b/clients/vscode/src/lsp/ChatFeature.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "events"; +import { CancellationToken } from "vscode"; +import { BaseLanguageClient, DynamicFeature, FeatureState, RegistrationData } from "vscode-languageclient"; +import { + ServerCapabilities, + ChatFeatureRegistration, + GenerateCommitMessageRequest, + GenerateCommitMessageParams, + GenerateCommitMessageResult, +} from "tabby-agent"; + +export class ChatFeature extends EventEmitter implements DynamicFeature { + private registration: string | undefined = undefined; + constructor(private readonly client: BaseLanguageClient) { + super(); + } + + readonly registrationType = ChatFeatureRegistration.type; + + getState(): FeatureState { + return { kind: "workspace", id: this.registrationType.method, registrations: this.isAvailable }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(): void { + // nothing + } + + preInitialize(): void { + // nothing + } + + initialize(capabilities: ServerCapabilities): void { + if (capabilities.tabby?.chat) { + this.register({ id: this.registrationType.method, registerOptions: {} }); + } + } + + register(data: RegistrationData): void { + this.registration = data.id; + this.emit("didChangeAvailability", true); + } + + unregister(id: string): void { + if (this.registration === id) { + this.registration = undefined; + this.emit("didChangeAvailability", false); + } + } + + clear(): void { + // nothing + } + + get isAvailable(): boolean { + return !!this.registration; + } + + async generateCommitMessage( + params: GenerateCommitMessageParams, + token?: CancellationToken, + ): Promise { + if (!this.isAvailable) { + return null; + } + return this.client.sendRequest(GenerateCommitMessageRequest.method, params, token); + } +} diff --git a/clients/vscode/src/lsp/Client.ts b/clients/vscode/src/lsp/Client.ts new file mode 100644 index 000000000000..16dbe81bdcc1 --- /dev/null +++ b/clients/vscode/src/lsp/Client.ts @@ -0,0 +1,72 @@ +import { ExtensionContext } from "vscode"; +import { BaseLanguageClient } from "vscode-languageclient"; +import { AgentFeature } from "./AgentFeature"; +import { ChatFeature } from "./ChatFeature"; +import { ConfigurationMiddleware } from "./ConfigurationMiddleware"; +import { ConfigurationSyncFeature } from "./ConfigurationSyncFeature"; +import { DataStoreFeature } from "./DataStoreFeature"; +import { EditorOptionsFeature } from "./EditorOptionsFeature"; +import { GitProviderFeature } from "./GitProviderFeature"; +import { InitializationFeature } from "./InitializationFeature"; +import { InlineCompletionFeature } from "./InlineCompletionFeature"; +import { LanguageSupportFeature } from "./LanguageSupportFeature"; +import { TelemetryFeature } from "./TelemetryFeature"; +import { WorkspaceFileSystemFeature } from "./WorkspaceFileSystemFeature"; +import { Config } from "../Config"; +import { InlineCompletionProvider } from "../InlineCompletionProvider"; +import { GitProvider } from "../git/GitProvider"; +import { getLogger } from "../logger"; + +export class Client { + private readonly logger = getLogger(""); + readonly agent: AgentFeature; + readonly chat: ChatFeature; + readonly telemetry: TelemetryFeature; + constructor( + private readonly context: ExtensionContext, + readonly languageClient: BaseLanguageClient, + ) { + this.agent = new AgentFeature(this.languageClient); + this.chat = new ChatFeature(this.languageClient); + this.telemetry = new TelemetryFeature(this.languageClient); + this.languageClient.registerFeature(this.agent); + this.languageClient.registerFeature(this.chat); + this.languageClient.registerFeature(this.telemetry); + this.languageClient.registerFeature(new DataStoreFeature(this.context, this.languageClient)); + this.languageClient.registerFeature(new EditorOptionsFeature(this.languageClient)); + this.languageClient.registerFeature(new LanguageSupportFeature(this.languageClient)); + this.languageClient.registerFeature(new WorkspaceFileSystemFeature(this.languageClient)); + } + + async start(): Promise { + await this.languageClient.start(); + } + + async stop(): Promise { + return this.languageClient.stop(); + } + + registerConfigManager(config: Config): void { + const initializationFeature = new InitializationFeature(this.context, this.languageClient, config, this.logger); + this.languageClient.registerFeature(initializationFeature); + + const configMiddleware = new ConfigurationMiddleware(config); + this.languageClient.middleware.workspace = { + ...this.languageClient.middleware.workspace, + ...configMiddleware, + }; + + const configSyncFeature = new ConfigurationSyncFeature(this.languageClient, config); + this.languageClient.registerFeature(configSyncFeature); + } + + registerInlineCompletionProvider(provider: InlineCompletionProvider): void { + const feature = new InlineCompletionFeature(this.languageClient, provider); + this.languageClient.registerFeature(feature); + } + + registerGitProvider(provider: GitProvider): void { + const feature = new GitProviderFeature(this.languageClient, provider); + this.languageClient.registerFeature(feature); + } +} diff --git a/clients/vscode/src/lsp/ConfigurationMiddleware.ts b/clients/vscode/src/lsp/ConfigurationMiddleware.ts new file mode 100644 index 000000000000..616a17f83f93 --- /dev/null +++ b/clients/vscode/src/lsp/ConfigurationMiddleware.ts @@ -0,0 +1,11 @@ +import { ConfigurationMiddleware as VscodeLspConfigurationMiddleware } from "vscode-languageclient"; +import { ClientProvidedConfig } from "tabby-agent"; +import { Config } from "../Config"; + +export class ConfigurationMiddleware implements VscodeLspConfigurationMiddleware { + constructor(private readonly config: Config) {} + + async configuration(): Promise { + return [this.config.buildClientProvidedConfig()]; + } +} diff --git a/clients/vscode/src/lsp/ConfigurationSyncFeature.ts b/clients/vscode/src/lsp/ConfigurationSyncFeature.ts new file mode 100644 index 000000000000..e343b4bdbe5e --- /dev/null +++ b/clients/vscode/src/lsp/ConfigurationSyncFeature.ts @@ -0,0 +1,42 @@ +import { BaseLanguageClient, StaticFeature, FeatureState, ClientCapabilities } from "vscode-languageclient"; +import { DidChangeConfigurationNotification, ClientProvidedConfig } from "tabby-agent"; +import { Config } from "../Config"; + +export class ConfigurationSyncFeature implements StaticFeature { + constructor( + private readonly client: BaseLanguageClient, + private readonly config: Config, + ) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.workspace = { + ...capabilities.workspace, + didChangeConfiguration: { dynamicRegistration: false }, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.config.on("updated", this.listener); + } + + clear(): void { + this.config.off("updated", this.listener); + } + + private listener = () => { + const clientProvidedConfig: ClientProvidedConfig = this.config.buildClientProvidedConfig(); + this.client.sendNotification(DidChangeConfigurationNotification.method, { settings: clientProvidedConfig }); + }; +} diff --git a/clients/vscode/src/lsp/DataStoreFeature.ts b/clients/vscode/src/lsp/DataStoreFeature.ts new file mode 100644 index 000000000000..0d36718dd0dc --- /dev/null +++ b/clients/vscode/src/lsp/DataStoreFeature.ts @@ -0,0 +1,65 @@ +import { env, ExtensionContext } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { + ClientCapabilities, + DataStoreGetRequest, + DataStoreGetParams, + DataStoreSetRequest, + DataStoreSetParams, +} from "tabby-agent"; + +export class DataStoreFeature implements StaticFeature { + private disposables: Disposable[] = []; + + constructor( + private readonly context: ExtensionContext, + private readonly client: BaseLanguageClient, + ) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + if (env.appHost === "desktop") { + return; + } + capabilities.tabby = { + ...capabilities.tabby, + dataStore: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + if (env.appHost === "desktop") { + return; + } + this.disposables.push( + this.client.onRequest(DataStoreGetRequest.type, (params: DataStoreGetParams) => { + const data: Record = this.context.globalState.get("data", {}); + return data[params.key]; + }), + ); + this.disposables.push( + this.client.onRequest(DataStoreSetRequest.type, async (params: DataStoreSetParams) => { + const data: Record = this.context.globalState.get("data", {}); + data[params.key] = params.value; + this.context.globalState.update("data", data); + return true; + }), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } +} diff --git a/clients/vscode/src/lsp/EditorOptionsFeature.ts b/clients/vscode/src/lsp/EditorOptionsFeature.ts new file mode 100644 index 000000000000..201a6b878c10 --- /dev/null +++ b/clients/vscode/src/lsp/EditorOptionsFeature.ts @@ -0,0 +1,54 @@ +import { window } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { ClientCapabilities, EditorOptionsRequest, EditorOptionsParams } from "tabby-agent"; + +export class EditorOptionsFeature implements StaticFeature { + private disposables: Disposable[] = []; + + constructor(private readonly client: BaseLanguageClient) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.tabby = { + ...capabilities.tabby, + editorOptions: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onRequest(EditorOptionsRequest.type, (params: EditorOptionsParams) => { + const editor = window.visibleTextEditors.find((editor) => editor.document.uri.toString() === params.uri); + if (!editor) { + return null; + } + const { insertSpaces, tabSize } = editor.options; + let indentation: string | undefined; + if (insertSpaces && typeof tabSize === "number" && tabSize > 0) { + indentation = " ".repeat(tabSize); + } else if (!insertSpaces) { + indentation = "\t"; + } + return { + indentation, + }; + }), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } +} diff --git a/clients/vscode/src/lsp/GitProviderFeature.ts b/clients/vscode/src/lsp/GitProviderFeature.ts new file mode 100644 index 000000000000..2e2d87ac2e74 --- /dev/null +++ b/clients/vscode/src/lsp/GitProviderFeature.ts @@ -0,0 +1,79 @@ +import { Uri } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { + ClientCapabilities, + GitRepositoryRequest, + GitRepositoryParams, + GitDiffRequest, + GitDiffParams, +} from "tabby-agent"; +import { GitProvider } from "../git/GitProvider"; + +export class GitProviderFeature implements StaticFeature { + private disposables: Disposable[] = []; + + constructor( + private readonly client: BaseLanguageClient, + private readonly gitProvider: GitProvider, + ) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.tabby = { + ...capabilities.tabby, + gitProvider: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onRequest(GitRepositoryRequest.type, (params: GitRepositoryParams) => { + const repository = this.gitProvider.getRepository(Uri.parse(params.uri)); + if (!repository) { + return null; + } + return { + root: repository.rootUri.toString(), + remoteUrl: this.gitProvider.getDefaultRemoteUrl(repository), + remotes: repository.state.remotes + .map((remote) => ({ + name: remote.name, + url: remote.fetchUrl ?? remote.pushUrl ?? "", + })) + .filter((remote) => { + return remote.url.length > 0; + }), + }; + }), + ); + this.disposables.push( + this.client.onRequest(GitDiffRequest.type, async (params: GitDiffParams) => { + const repository = this.gitProvider.getRepository(Uri.parse(params.repository)); + if (!repository) { + return null; + } + const diff = await this.gitProvider.getDiff(repository, params.cached); + if (!diff) { + return null; + } + return { diff }; + }), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } +} diff --git a/clients/vscode/src/lsp/InitializationFeature.ts b/clients/vscode/src/lsp/InitializationFeature.ts new file mode 100644 index 000000000000..238a4b3e22a5 --- /dev/null +++ b/clients/vscode/src/lsp/InitializationFeature.ts @@ -0,0 +1,65 @@ +import { env, version, ExtensionContext, LogOutputChannel, LogLevel } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Trace } from "vscode-languageclient"; +import { InitializeParams } from "tabby-agent"; +import { Config } from "../Config"; + +export class InitializationFeature implements StaticFeature { + constructor( + private readonly context: ExtensionContext, + private readonly client: BaseLanguageClient, + private readonly config: Config, + private readonly logger: LogOutputChannel, + ) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams(params: InitializeParams) { + params.clientInfo = { + ...params.clientInfo, + name: `${env.appName} ${env.appHost}`, + version: version, + tabbyPlugin: { + name: this.context.extension.id, + version: this.context.extension.packageJSON.version, + }, + }; + params.initializationOptions = { + ...params.initializationOptions, + config: this.config.buildClientProvidedConfig(), + }; + params.trace = this.getCurrentTraceValue(); + } + + fillClientCapabilities(): void { + // nothing + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + // Sync trace setting + this.client.setTrace(Trace.fromString(this.getCurrentTraceValue())); + this.context.subscriptions.push( + this.logger.onDidChangeLogLevel(async () => { + await this.client.setTrace(Trace.fromString(this.getCurrentTraceValue())); + }), + ); + } + + clear(): void { + // nothing + } + + private getCurrentTraceValue(): "verbose" | "off" { + const level = this.logger.logLevel; + if (level === LogLevel.Trace) { + return "verbose"; + } else { + return "off"; + } + } +} diff --git a/clients/vscode/src/lsp/InlineCompletionFeature.ts b/clients/vscode/src/lsp/InlineCompletionFeature.ts new file mode 100644 index 000000000000..ff62e2f22064 --- /dev/null +++ b/clients/vscode/src/lsp/InlineCompletionFeature.ts @@ -0,0 +1,23 @@ +import { languages, InlineCompletionItemProvider, Disposable } from "vscode"; +import { InlineCompletionRegistrationOptions, FeatureClient } from "vscode-languageclient"; +import { + InlineCompletionMiddleware, + InlineCompletionItemFeature as VscodeLspInlineCompletionItemFeature, +} from "vscode-languageclient/lib/common/inlineCompletion"; + +export class InlineCompletionFeature extends VscodeLspInlineCompletionItemFeature { + constructor( + client: FeatureClient, + private readonly inlineCompletionItemProvider: InlineCompletionItemProvider, + ) { + super(client); + } + + override registerLanguageProvider( + options: InlineCompletionRegistrationOptions, + ): [Disposable, InlineCompletionItemProvider] { + const selector = this._client.protocol2CodeConverter.asDocumentSelector(options.documentSelector ?? ["**"]); + const provider = this.inlineCompletionItemProvider; + return [languages.registerInlineCompletionItemProvider(selector, provider), provider]; + } +} diff --git a/clients/vscode/src/lsp/LanguageSupportFeature.ts b/clients/vscode/src/lsp/LanguageSupportFeature.ts new file mode 100644 index 000000000000..cc2f726fa5ca --- /dev/null +++ b/clients/vscode/src/lsp/LanguageSupportFeature.ts @@ -0,0 +1,108 @@ +import { commands, Uri, Position, Range } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { + ClientCapabilities, + LanguageSupportDeclarationRequest, + LanguageSupportSemanticTokensRangeRequest, +} from "tabby-agent"; +import { DeclarationParams, SemanticTokensRangeParams } from "vscode-languageclient"; + +export class LanguageSupportFeature implements StaticFeature { + private disposables: Disposable[] = []; + + constructor(private readonly client: BaseLanguageClient) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.tabby = { + ...capabilities.tabby, + languageSupport: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onRequest(LanguageSupportDeclarationRequest.type, async (params: DeclarationParams) => { + const result = await commands.executeCommand( + "vscode.executeDefinitionProvider", + Uri.parse(params.textDocument.uri), + new Position(params.position.line, params.position.character), + ); + const items = Array.isArray(result) ? result : [result]; + const locations = items.map((item) => { + return { + uri: "targetUri" in item ? item.targetUri.toString() : item.uri.toString(), + range: + "targetRange" in item + ? { + start: { + line: item.targetRange.start.line, + character: item.targetRange.start.character, + }, + end: { + line: item.targetRange.end.line, + character: item.targetRange.end.character, + }, + } + : { + start: { + line: item.range.start.line, + character: item.range.start.character, + }, + end: { + line: item.range.end.line, + character: item.range.end.character, + }, + }, + }; + }); + return locations; + }), + ); + this.disposables.push( + this.client.onRequest( + LanguageSupportSemanticTokensRangeRequest.type, + async (params: SemanticTokensRangeParams) => { + return { + legend: await commands.executeCommand( + "vscode.provideDocumentRangeSemanticTokensLegend", + Uri.parse(params.textDocument.uri), + new Range( + params.range.start.line, + params.range.start.character, + params.range.end.line, + params.range.end.character, + ), + ), + tokens: await commands.executeCommand( + "vscode.provideDocumentRangeSemanticTokens", + Uri.parse(params.textDocument.uri), + new Range( + params.range.start.line, + params.range.start.character, + params.range.end.line, + params.range.end.character, + ), + ), + }; + }, + ), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } +} diff --git a/clients/vscode/src/lsp/TelemetryFeature.ts b/clients/vscode/src/lsp/TelemetryFeature.ts new file mode 100644 index 000000000000..5331f844dd43 --- /dev/null +++ b/clients/vscode/src/lsp/TelemetryFeature.ts @@ -0,0 +1,34 @@ +import { BaseLanguageClient, StaticFeature, FeatureState } from "vscode-languageclient"; +import { TelemetryEventNotification, EventParams } from "tabby-agent"; + +export class TelemetryFeature implements StaticFeature { + constructor(private readonly client: BaseLanguageClient) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(): void { + // nothing + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + // nothing + } + + clear(): void { + // nothing + } + + async postEvent(params: EventParams): Promise { + return this.client.sendNotification(TelemetryEventNotification.method, params); + } +} diff --git a/clients/vscode/src/lsp/WorkspaceFileSystemFeature.ts b/clients/vscode/src/lsp/WorkspaceFileSystemFeature.ts new file mode 100644 index 000000000000..bfdfa165ed98 --- /dev/null +++ b/clients/vscode/src/lsp/WorkspaceFileSystemFeature.ts @@ -0,0 +1,55 @@ +import { workspace, Range, Uri } from "vscode"; +import { BaseLanguageClient, StaticFeature, FeatureState, Disposable } from "vscode-languageclient"; +import { ClientCapabilities, ReadFileRequest, ReadFileParams } from "tabby-agent"; + +export class WorkspaceFileSystemFeature implements StaticFeature { + private disposables: Disposable[] = []; + + constructor(private readonly client: BaseLanguageClient) {} + + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + capabilities.tabby = { + ...capabilities.tabby, + workspaceFileSystem: true, + }; + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onRequest(ReadFileRequest.type, async (params: ReadFileParams) => { + if (params.format !== "text") { + return null; + } + const textDocument = await workspace.openTextDocument(Uri.parse(params.uri)); + const range = params.range + ? new Range( + params.range.start.line, + params.range.start.character, + params.range.end.line, + params.range.end.character, + ) + : undefined; + return { + text: textDocument.getText(range), + }; + }), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } +} diff --git a/clients/vscode/src/notifications.ts b/clients/vscode/src/notifications.ts deleted file mode 100644 index 8bb61f397f3a..000000000000 --- a/clients/vscode/src/notifications.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { commands, window, workspace, ConfigurationTarget } from "vscode"; -import type { - HighCompletionTimeoutRateIssue, - SlowCompletionResponseTimeIssue, - ConnectionFailedIssue, -} from "tabby-agent"; -import { agent } from "./agent"; - -function showInformationWhenInitializing() { - window.showInformationMessage("Tabby is initializing.", "Settings").then((selection) => { - switch (selection) { - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); -} - -function showInformationWhenAutomaticTrigger() { - window - .showInformationMessage( - "Tabby automatic code completion is enabled. Switch to manual trigger mode?", - "Manual Mode", - "Settings", - ) - .then((selection) => { - switch (selection) { - case "Manual Mode": - commands.executeCommand("tabby.toggleInlineCompletionTriggerMode", "manual"); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); -} - -function showInformationWhenManualTrigger() { - window - .showInformationMessage( - "Tabby is standing by. Trigger code completion manually?", - "Trigger", - "Automatic Mode", - "Settings", - ) - .then((selection) => { - switch (selection) { - case "Trigger": - commands.executeCommand("editor.action.inlineSuggest.trigger"); - break; - case "Automatic Mode": - commands.executeCommand("tabby.toggleInlineCompletionTriggerMode", "automatic"); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); -} - -function showInformationWhenManualTriggerLoading() { - window.showInformationMessage("Tabby is generating code completions.", "Settings").then((selection) => { - switch (selection) { - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); -} - -function showInformationWhenInlineSuggestDisabled() { - window - .showWarningMessage( - "Tabby's suggestion is not showing because inline suggestion is disabled. Please enable it first.", - "Enable", - "Settings", - ) - .then((selection) => { - switch (selection) { - case "Enable": - workspace.getConfiguration("editor").update("inlineSuggest.enabled", true, ConfigurationTarget.Global, false); - break; - case "Settings": - commands.executeCommand("workbench.action.openSettings", "@id:editor.inlineSuggest.enabled"); - break; - } - }); -} - -function showInformationWhenDisconnected(modal: boolean = false) { - if (modal) { - const message = agent().getIssueDetail({ name: "connectionFailed" })?.message; - window - .showWarningMessage( - `Cannot connect to Tabby Server.`, - { - modal: true, - detail: message, - }, - "Settings", - "Online Help...", - ) - .then((selection) => { - switch (selection) { - case "Online Help...": - commands.executeCommand("tabby.openOnlineHelp"); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); - } else { - window.showWarningMessage(`Cannot connect to Tabby Server.`, "Detail", "Settings").then((selection) => { - switch (selection) { - case "Detail": - showInformationWhenDisconnected(true); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); - } -} - -function showInformationWhenUnauthorized() { - let message = "Tabby server requires authentication, "; - const currentToken = agent().getConfig()["server"]["token"].trim(); - if (currentToken.length > 0) { - message += ` but the current token is invalid.`; - } else { - message += ` please set your personal token.`; - } - window.showWarningMessage(message, "Set Credentials").then((selection) => { - switch (selection) { - case "Set Credentials": - commands.executeCommand("tabby.setApiToken"); - break; - } - }); -} - -/** @deprecated Tabby Cloud auth */ -function showInformationStartAuth(callbacks?: { onAuthStart?: () => void; onAuthEnd?: () => void }) { - window - .showWarningMessage( - "Tabby Server requires authorization. Continue to open authorization page in your browser.", - "Continue", - "Settings", - ) - .then((selection) => { - switch (selection) { - case "Continue": - commands.executeCommand("tabby.openAuthPage", callbacks); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - } - }); -} - -/** @deprecated Tabby Cloud auth */ -function showInformationAuthSuccess() { - window.showInformationMessage("Congrats, you're authorized, start to use Tabby now."); -} - -/** @deprecated Tabby Cloud auth */ -function showInformationWhenStartAuthButAlreadyAuthorized() { - window.showInformationMessage("You are already authorized now."); -} - -/** @deprecated Tabby Cloud auth */ -function showInformationWhenAuthFailed() { - window.showWarningMessage("Cannot connect to server. Please check settings.", "Settings").then((selection) => { - switch (selection) { - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - } - }); -} - -function getHelpMessageForCompletionResponseTimeIssue() { - let helpMessageForRunningLargeModelOnCPU = ""; - const serverHealthState = agent().getServerHealthState(); - if (serverHealthState?.device === "cpu" && serverHealthState?.model?.match(/[0-9.]+B$/)) { - helpMessageForRunningLargeModelOnCPU += - `Your Tabby server is running model ${serverHealthState?.model} on CPU. ` + - "This model may be performing poorly due to its large parameter size, please consider trying smaller models or switch to GPU. " + - "You can find a list of recommend models in the online documentation.\n"; - } - let commonHelpMessage = ""; - const host = new URL(agent().getConfig().server.endpoint).host; - if (helpMessageForRunningLargeModelOnCPU.length == 0) { - commonHelpMessage += ` - The running model ${ - serverHealthState?.model ?? "" - } may be performing poorly due to its large parameter size. `; - commonHelpMessage += - "Please consider trying smaller models. You can find a list of recommend models in the online documentation.\n"; - } - if (!(host.startsWith("localhost") || host.startsWith("127.0.0.1"))) { - commonHelpMessage += " - A poor network connection. Please check your network and proxy settings.\n"; - commonHelpMessage += " - Server overload. Please contact your Tabby server administrator for assistance.\n"; - } - let message = ""; - if (helpMessageForRunningLargeModelOnCPU.length > 0) { - message += helpMessageForRunningLargeModelOnCPU + "\n"; - if (commonHelpMessage.length > 0) { - message += "Other possible causes of this issue: \n"; - message += commonHelpMessage; - } - } else { - // commonHelpMessage should not be empty here - message += "Possible causes of this issue: \n"; - message += commonHelpMessage; - } - return message; -} - -function showInformationWhenSlowCompletionResponseTime(modal: boolean = false) { - if (modal) { - const stats = agent().getIssueDetail({ - name: "slowCompletionResponseTime", - })?.completionResponseStats; - let statsMessage = ""; - if (stats && stats["responses"] && stats["averageResponseTime"]) { - statsMessage = `The average response time of recent ${stats["responses"]} completion requests is ${Number( - stats["averageResponseTime"], - ).toFixed(0)}ms.\n\n`; - } - window - .showWarningMessage( - "Completion requests appear to take too much time.", - { - modal: true, - detail: statsMessage + getHelpMessageForCompletionResponseTimeIssue(), - }, - "Online Help...", - "Don't Show Again", - ) - .then((selection) => { - switch (selection) { - case "Online Help...": - commands.executeCommand("tabby.openOnlineHelp"); - break; - case "Don't Show Again": - commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); - break; - } - }); - } else { - window - .showWarningMessage("Completion requests appear to take too much time.", "Detail", "Settings", "Don't Show Again") - .then((selection) => { - switch (selection) { - case "Detail": - showInformationWhenSlowCompletionResponseTime(true); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - case "Don't Show Again": - commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); - break; - } - }); - } -} - -function showInformationWhenHighCompletionTimeoutRate(modal: boolean = false) { - if (modal) { - const stats = agent().getIssueDetail({ - name: "highCompletionTimeoutRate", - })?.completionResponseStats; - let statsMessage = ""; - if (stats && stats["total"] && stats["timeouts"]) { - statsMessage = `${stats["timeouts"]} of ${stats["total"]} completion requests timed out.\n\n`; - } - window - .showWarningMessage( - "Most completion requests timed out.", - { - modal: true, - detail: statsMessage + getHelpMessageForCompletionResponseTimeIssue(), - }, - "Online Help...", - "Don't Show Again", - ) - .then((selection) => { - switch (selection) { - case "Online Help...": - commands.executeCommand("tabby.openOnlineHelp"); - break; - case "Don't Show Again": - commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); - break; - } - }); - } else { - window - .showWarningMessage("Most completion requests timed out.", "Detail", "Settings", "Don't Show Again") - .then((selection) => { - switch (selection) { - case "Detail": - showInformationWhenHighCompletionTimeoutRate(true); - break; - case "Settings": - commands.executeCommand("tabby.openSettings"); - break; - case "Don't Show Again": - commands.executeCommand("tabby.notifications.mute", "completionResponseTimeIssues"); - break; - } - }); - } -} - -export const notifications = { - showInformationWhenInitializing, - showInformationWhenAutomaticTrigger, - showInformationWhenManualTrigger, - showInformationWhenManualTriggerLoading, - showInformationWhenInlineSuggestDisabled, - showInformationWhenDisconnected, - showInformationWhenUnauthorized, - showInformationStartAuth, - showInformationAuthSuccess, - showInformationWhenStartAuthButAlreadyAuthorized, - showInformationWhenAuthFailed, - showInformationWhenSlowCompletionResponseTime, - showInformationWhenHighCompletionTimeoutRate, -}; diff --git a/clients/vscode/src/utils.ts b/clients/vscode/src/utils.ts deleted file mode 100644 index 0cd9eb308e70..000000000000 --- a/clients/vscode/src/utils.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { commands, Position, Range, SemanticTokens, SemanticTokensLegend, TextDocument } from "vscode"; - -export type SemanticSymbolInfo = { - position: Position; - type: string; -}; - -// reference: https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide -export async function extractSemanticSymbols( - document: TextDocument, - range: Range, -): Promise { - const providedTokens = await commands.executeCommand( - "vscode.provideDocumentRangeSemanticTokens", - document.uri, - range, - ); - if ( - typeof providedTokens === "object" && - providedTokens !== null && - "resultId" in providedTokens && - "data" in providedTokens - ) { - const tokens = providedTokens as SemanticTokens; - const providedLegend = await commands.executeCommand( - "vscode.provideDocumentRangeSemanticTokensLegend", - document.uri, - range, - ); - if ( - typeof providedLegend === "object" && - providedLegend !== null && - "tokenTypes" in providedLegend && - "tokenModifiers" in providedLegend - ) { - const legend = providedLegend as SemanticTokensLegend; - - const semanticSymbols: SemanticSymbolInfo[] = []; - let line = 0; - let char = 0; - for (let i = 0; i + 4 < tokens.data.length; i += 5) { - const deltaLine = tokens.data[i]!; - const deltaChar = tokens.data[i + 1]!; - // i + 2 is token length, not used here - const type = legend.tokenTypes[tokens.data[i + 3]!] ?? ""; - // i + 4 is type modifiers, not used here - - line += deltaLine; - if (deltaLine > 0) { - char = deltaChar; - } else { - char += deltaChar; - } - semanticSymbols.push({ - position: new Position(line, char), - type, - }); - } - return semanticSymbols; - } - } - return undefined; -} - -// Keywords appear in the code everywhere, but we don't want to use them for -// matching in code searching. -// Just filter them out before we start using a syntax parser. -const reservedKeywords = [ - // Typescript: https://github.com/microsoft/TypeScript/issues/2536 - "as", - "any", - "boolean", - "break", - "case", - "catch", - "class", - "const", - "constructor", - "continue", - "debugger", - "declare", - "default", - "delete", - "do", - "else", - "enum", - "export", - "extends", - "false", - "finally", - "for", - "from", - "function", - "get", - "if", - "implements", - "import", - "in", - "instanceof", - "interface", - "let", - "module", - "new", - "null", - "number", - "of", - "package", - "private", - "protected", - "public", - "require", - "return", - "set", - "static", - "string", - "super", - "switch", - "symbol", - "this", - "throw", - "true", - "try", - "typeof", - "var", - "void", - "while", - "with", - "yield", -]; -export function extractNonReservedWordList(text: string): string { - const re = /\w+/g; - return [ - ...new Set(text.match(re)?.filter((symbol) => symbol.length > 2 && !reservedKeywords.includes(symbol))).values(), - ].join(" "); -} diff --git a/clients/vscode/tsconfig.json b/clients/vscode/tsconfig.json index 37953d57d106..920eb6ef63ec 100644 --- a/clients/vscode/tsconfig.json +++ b/clients/vscode/tsconfig.json @@ -1,15 +1,17 @@ { "compilerOptions": { "module": "commonjs", - "target": "ES2020", - "lib": ["ES2020", "dom"], + "lib": ["esnext", "webworker"], + "target": "es2020", "sourceMap": true, "strict": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, "noUnusedParameters": true, "allowSyntheticDefaultImports": true }, - "include": ["./src"] + "include": ["./src/"] } diff --git a/clients/vscode/tsup.config.ts b/clients/vscode/tsup.config.ts index 0b9f5995d254..9149806ee5bc 100644 --- a/clients/vscode/tsup.config.ts +++ b/clients/vscode/tsup.config.ts @@ -1,62 +1,66 @@ -import type { Plugin } from "esbuild"; import { defineConfig } from "tsup"; +import path from "path"; +import { getInstalledPath } from "get-installed-path"; import { copy } from "esbuild-plugin-copy"; import { polyfillNode } from "esbuild-plugin-polyfill-node"; +import dedent from "dedent"; import { dependencies } from "./package.json"; -function handleWinCaNativeBinaries(): Plugin { - return { - name: "handleWinCaNativeBinaries", - setup: (build) => { - build.onLoad({ filter: /win-ca\/lib\/crypt32-\w*.node$/ }, async (args) => { - // As win-ca fallback is used, skip not required `.node` binaries - return { - contents: "", - loader: "empty", - }; - }); - }, - }; -} +const banner = dedent` + /** + * Tabby VSCode Extension + * https://github.com/tabbyml/tabby/tree/main/clients/vscode + * Copyright (c) 2023-2024 TabbyML, Inc. + * Licensed under the Apache License 2.0. + */`; -export default () => [ - defineConfig({ - name: "node", - entry: ["src/extension.ts"], - outDir: "dist/node", - platform: "node", - target: "node18", - external: ["vscode"], - noExternal: Object.keys(dependencies), - esbuildPlugins: [ - copy({ - assets: [ - { - from: "../tabby-agent/dist/wasm/*", - to: "./wasm", - }, - { - from: "../tabby-agent/dist/win-ca/*", - to: "./win-ca", - }, - ], - }), - handleWinCaNativeBinaries(), - ], - clean: true, - }), - defineConfig({ - name: "browser", - entry: ["src/extension.ts"], - outDir: "dist/web", - platform: "browser", - external: ["vscode"], - noExternal: Object.keys(dependencies), - esbuildPlugins: [ - polyfillNode({ - polyfills: { fs: true }, - }), - ], - clean: true, - }), -]; +export default defineConfig(async () => { + const tabbyAgentDist = path.join(await getInstalledPath("tabby-agent", { local: true }), "dist"); + return [ + { + name: "node", + entry: ["src/extension.ts"], + outDir: "dist/node", + platform: "node", + target: "node18", + sourcemap: true, + banner: { + js: banner, + }, + external: ["vscode"], + noExternal: Object.keys(dependencies), + esbuildPlugins: [ + copy({ + assets: { from: `${tabbyAgentDist}/**`, to: "dist/tabby-agent" }, + resolveFrom: "cwd", + }), + ], + define: { + "process.env.IS_BROWSER": "false", + }, + clean: true, + }, + { + name: "browser", + entry: ["src/extension.ts"], + outDir: "dist/browser", + platform: "browser", + target: "node18", + sourcemap: true, + banner: { + js: banner, + }, + external: ["vscode"], + noExternal: Object.keys(dependencies), + esbuildPlugins: [ + polyfillNode({ + polyfills: { fs: true }, + }), + ], + define: { + "process.env.IS_BROWSER": "true", + }, + clean: true, + }, + ]; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 806c1cce253b..92eb41011e86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,80 +44,10 @@ importers: version: 1.5.0 clients/tabby-agent: - dependencies: - axios: - specifier: ^1.7.2 - version: 1.7.2 - chokidar: - specifier: ^3.5.3 - version: 3.6.0 - deep-equal: - specifier: ^2.2.1 - version: 2.2.3 - deepmerge-ts: - specifier: ^5.1.0 - version: 5.1.0 - dot-prop: - specifier: ^8.0.2 - version: 8.0.2 - eventsource-parser: - specifier: ^1.1.2 - version: 1.1.2 - fast-levenshtein: - specifier: ^3.0.0 - version: 3.0.0 - file-stream-rotator: - specifier: ^1.0.0 - version: 1.0.0 - form-data: - specifier: ^4.0.0 - version: 4.0.0 - fs-extra: - specifier: ^11.1.1 - version: 11.2.0 - jwt-decode: - specifier: ^3.1.2 - version: 3.1.2 - lru-cache: - specifier: ^9.1.1 - version: 9.1.2 - mac-ca: - specifier: ^2.0.3 - version: 2.0.3 - object-hash: - specifier: ^3.0.0 - version: 3.0.0 - openapi-fetch: - specifier: ^0.7.6 - version: 0.7.10 - pino: - specifier: ^8.14.1 - version: 8.21.0 - semver: - specifier: ^7.6.0 - version: 7.6.2 - stats-logscale: - specifier: ^1.0.9 - version: 1.0.9 - toml: - specifier: ^3.0.0 - version: 3.0.0 - uuid: - specifier: ^9.0.0 - version: 9.0.1 - vscode-languageserver: - specifier: ^9.0.1 - version: 9.0.1 - vscode-languageserver-textdocument: - specifier: ^1.0.11 - version: 1.0.11 - web-tree-sitter: - specifier: ^0.20.8 - version: 0.20.8 - win-ca: - specifier: ^3.5.1 - version: 3.5.1 devDependencies: + '@orama/orama': + specifier: ^2.0.18 + version: 2.0.18 '@types/chai': specifier: ^4.3.5 version: 4.3.16 @@ -163,9 +93,21 @@ importers: chai: specifier: ^4.3.7 version: 4.4.1 + chokidar: + specifier: ^3.5.3 + version: 3.6.0 dedent: specifier: ^0.7.0 version: 0.7.0 + deep-equal: + specifier: ^2.2.1 + version: 2.2.3 + deepmerge-ts: + specifier: ^5.1.0 + version: 5.1.0 + dot-prop: + specifier: ^8.0.2 + version: 8.0.2 esbuild-plugin-copy: specifier: ^2.1.1 version: 2.1.1(esbuild@0.19.12) @@ -178,27 +120,87 @@ importers: eslint-config-prettier: specifier: ^9.0.0 version: 9.1.0(eslint@8.57.0) + eventsource-parser: + specifier: ^1.1.2 + version: 1.1.2 + fast-levenshtein: + specifier: ^3.0.0 + version: 3.0.0 + file-stream-rotator: + specifier: ^1.0.0 + version: 1.0.0 + fs-extra: + specifier: ^11.1.1 + version: 11.2.0 glob: specifier: ^7.2.0 version: 7.2.3 + jwt-decode: + specifier: ^3.1.2 + version: 3.1.2 + lru-cache: + specifier: ^9.1.1 + version: 9.1.2 + mac-ca: + specifier: ^2.0.3 + version: 2.0.3 mocha: specifier: ^10.2.0 version: 10.4.0 + object-hash: + specifier: ^3.0.0 + version: 3.0.0 + openapi-fetch: + specifier: ^0.7.6 + version: 0.7.10 openapi-typescript: specifier: ^6.6.1 version: 6.7.6 + pino: + specifier: ^8.14.1 + version: 8.21.0 prettier: specifier: ^3.0.0 version: 3.2.5 + semver: + specifier: ^7.6.0 + version: 7.6.2 + stats-logscale: + specifier: ^1.0.9 + version: 1.0.9 + toml: + specifier: ^3.0.0 + version: 3.0.0 ts-node: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3) + tsc-watch: + specifier: ^6.2.0 + version: 6.2.0(typescript@5.3.3) tsup: - specifier: ^7.1.0 - version: 7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3))(typescript@5.3.3) + specifier: ^8.0.2 + version: 8.0.2(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3))(typescript@5.3.3) typescript: specifier: ^5.3.2 version: 5.3.3 + uuid: + specifier: ^9.0.0 + version: 9.0.1 + vscode-languageserver: + specifier: ^9.0.1 + version: 9.0.1 + vscode-languageserver-protocol: + specifier: ^3.17.5 + version: 3.17.5 + vscode-languageserver-textdocument: + specifier: ^1.0.11 + version: 1.0.11 + web-tree-sitter: + specifier: ^0.20.8 + version: 0.20.8 + win-ca: + specifier: ^3.5.1 + version: 3.5.1 clients/tabby-chat-panel: dependencies: @@ -263,21 +265,21 @@ importers: clients/vscode: dependencies: - '@orama/orama': - specifier: ^2.0.15 - version: 2.0.18 '@quilted/threads': specifier: ^2.2.0 version: 2.2.0 - '@xstate/fsm': - specifier: ^2.0.1 - version: 2.1.0 + object-hash: + specifier: ^3.0.0 + version: 3.0.0 tabby-agent: specifier: workspace:* version: link:../tabby-agent tabby-chat-panel: specifier: workspace:* version: link:../tabby-chat-panel + vscode-languageclient: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/mocha': specifier: ^10.0.1 @@ -285,6 +287,9 @@ importers: '@types/node': specifier: 18.x version: 18.19.33 + '@types/object-hash': + specifier: ^3.0.0 + version: 3.0.6 '@types/vscode': specifier: ^1.82.0 version: 1.89.0 @@ -306,6 +311,9 @@ importers: assert: specifier: ^2.0.0 version: 2.1.0 + dedent: + specifier: ^0.7.0 + version: 0.7.0 esbuild-plugin-copy: specifier: ^2.1.1 version: 2.1.1(esbuild@0.19.12) @@ -318,12 +326,18 @@ importers: eslint-config-prettier: specifier: ^9.0.0 version: 9.1.0(eslint@8.57.0) + get-installed-path: + specifier: ^4.0.8 + version: 4.0.8 prettier: specifier: ^3.0.0 version: 3.2.5 + tsc-watch: + specifier: ^6.2.0 + version: 6.2.0(typescript@5.4.5) tsup: - specifier: ^7.1.0 - version: 7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5))(typescript@5.4.5) + specifier: ^8.0.2 + version: 8.0.2(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5))(typescript@5.4.5) typescript: specifier: ^5.3.2 version: 5.4.5 @@ -3999,9 +4013,6 @@ packages: resolution: {integrity: sha512-q76lDAafvHNGWedNAVHrz/EyYTS8qwRLcwne8SJQdRN5P3HydxU6XROFvJfTML6KZXQX2FDdGY4/SnaNyd7M0Q==} engines: {node: '>=16.0.0'} - '@xstate/fsm@2.1.0': - resolution: {integrity: sha512-oJlc0iD0qZvAM7If/KlyJyqUt7wVI8ocpsnlWzAPl97evguPbd+oJbRM9R4A1vYJffYH96+Bx44nLDE6qS8jQg==} - '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4268,9 +4279,6 @@ packages: resolution: {integrity: sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==} engines: {node: '>=4'} - axios@1.7.2: - resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} - axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} @@ -5164,6 +5172,9 @@ packages: resolution: {integrity: sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==} engines: {node: '>=4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + duplexify@3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} @@ -5662,6 +5673,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -5696,6 +5710,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -5840,15 +5858,6 @@ packages: focus-trap@7.5.3: resolution: {integrity: sha512-7UsT/eSJcTPF0aZp73u7hBRTABz26knRRTJfoTGFCQD5mUImLIIOwWWCrtoQdmWa7dykBi6H+Cp5i3S/kvsMeA==} - follow-redirects@1.15.6: - resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -5892,6 +5901,9 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -5945,6 +5957,10 @@ packages: get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-installed-path@4.0.8: + resolution: {integrity: sha512-PmANK1xElIHlHH2tXfOoTnSDUjX1X3GvKK6ZyLbUnSCCn1pADwu67eVWttuPzJWrXDDT2MfO6uAaKILOFfitmA==} + engines: {node: '>=6', npm: '>=5', yarn: '>=1'} + get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} @@ -6015,6 +6031,14 @@ packages: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + global-modules@1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + + global-prefix@1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -6192,6 +6216,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -7029,6 +7057,9 @@ packages: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} + map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + markdown-it@12.3.2: resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} hasBin: true @@ -7465,6 +7496,9 @@ packages: node-addon-api@7.0.0: resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + node-cleanup@2.1.2: + resolution: {integrity: sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -7755,6 +7789,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + parse-semver@1.1.1: resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} @@ -7840,6 +7878,9 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -8226,8 +8267,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true pump@2.0.1: resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} @@ -8547,6 +8590,10 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-dir@1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -8908,6 +8955,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} @@ -8948,6 +8998,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} @@ -9321,6 +9374,13 @@ packages: '@swc/wasm': optional: true + tsc-watch@6.2.0: + resolution: {integrity: sha512-2LBhf9kjKXnz7KQ/puLHlozMzzUNHAdYBNMkg3eksQJ9GBAgMg8czznM83T5PmsoUvDnXzfIeQn2lNcIYDr8LA==} + engines: {node: '>=12.12.0'} + hasBin: true + peerDependencies: + typescript: '*' + tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} @@ -9354,6 +9414,25 @@ packages: typescript: optional: true + tsup@8.0.2: + resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + tsutils@3.21.0: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} @@ -14264,8 +14343,6 @@ snapshots: fast-url-parser: 1.1.3 tslib: 2.6.2 - '@xstate/fsm@2.1.0': {} - '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -14539,14 +14616,6 @@ snapshots: axe-core@4.8.2: {} - axios@1.7.2: - dependencies: - follow-redirects: 1.15.6 - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axobject-query@3.2.1: dependencies: dequal: 2.0.3 @@ -15512,6 +15581,8 @@ snapshots: dset@3.1.3: {} + duplexer@0.1.2: {} + duplexify@3.7.1: dependencies: end-of-stream: 1.4.4 @@ -16370,6 +16441,16 @@ snapshots: esutils@2.0.3: {} + event-stream@3.3.4: + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.1.0 + pause-stream: 0.0.11 + split: 0.3.3 + stream-combiner: 0.0.4 + through: 2.3.8 + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -16409,6 +16490,10 @@ snapshots: expand-template@2.0.3: optional: true + expand-tilde@2.0.2: + dependencies: + homedir-polyfill: 1.0.3 + extend@3.0.2: {} external-editor@3.1.0: @@ -16574,8 +16659,6 @@ snapshots: dependencies: tabbable: 6.2.0 - follow-redirects@1.15.6: {} - for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -16614,6 +16697,8 @@ snapshots: fresh@0.5.2: {} + from@0.1.7: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -16659,6 +16744,10 @@ snapshots: get-func-name@2.0.2: {} + get-installed-path@4.0.8: + dependencies: + global-modules: 1.0.0 + get-intrinsic@1.2.1: dependencies: function-bind: 1.1.2 @@ -16768,6 +16857,20 @@ snapshots: minimatch: 5.1.6 once: 1.4.0 + global-modules@1.0.0: + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + + global-prefix@1.0.2: + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + globals@11.12.0: {} globals@13.22.0: @@ -16988,6 +17091,10 @@ snapshots: highlight.js@10.7.3: {} + homedir-polyfill@1.0.3: + dependencies: + parse-passwd: 1.0.0 + hookable@5.5.3: {} hosted-git-info@2.8.9: {} @@ -17874,6 +17981,8 @@ snapshots: map-obj@4.3.0: {} + map-stream@0.1.0: {} + markdown-it@12.3.2: dependencies: argparse: 2.0.1 @@ -18520,6 +18629,8 @@ snapshots: node-addon-api@7.0.0: {} + node-cleanup@2.1.2: {} + node-domexception@1.0.0: {} node-fetch-native@1.6.4: {} @@ -18854,6 +18965,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-passwd@1.0.0: {} + parse-semver@1.1.1: dependencies: semver: 5.7.2 @@ -18925,6 +19038,10 @@ snapshots: pathval@1.1.1: {} + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + peberminta@0.9.0: {} peek-stream@1.1.3: @@ -19329,7 +19446,9 @@ snapshots: proto-list@1.2.4: {} - proxy-from-env@1.1.0: {} + ps-tree@1.2.0: + dependencies: + event-stream: 3.3.4 pump@2.0.1: dependencies: @@ -19806,6 +19925,11 @@ snapshots: require-main-filename@2.0.0: {} + resolve-dir@1.0.1: + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -20221,6 +20345,10 @@ snapshots: split2@4.2.0: {} + split@0.3.3: + dependencies: + through: 2.3.8 + split@1.0.1: dependencies: through: 2.3.8 @@ -20254,6 +20382,10 @@ snapshots: stoppable@1.1.0: {} + stream-combiner@0.0.4: + dependencies: + duplexer: 0.1.2 + stream-shift@1.0.3: {} streamsearch@1.1.0: {} @@ -20759,6 +20891,22 @@ snapshots: yn: 3.1.1 optional: true + tsc-watch@6.2.0(typescript@5.3.3): + dependencies: + cross-spawn: 7.0.3 + node-cleanup: 2.1.2 + ps-tree: 1.2.0 + string-argv: 0.3.2 + typescript: 5.3.3 + + tsc-watch@6.2.0(typescript@5.4.5): + dependencies: + cross-spawn: 7.0.3 + node-cleanup: 2.1.2 + ps-tree: 1.2.0 + string-argv: 0.3.2 + typescript: 5.4.5 + tsconfig-paths@3.14.2: dependencies: '@types/json5': 0.0.29 @@ -20774,7 +20922,7 @@ snapshots: tsscmp@1.0.6: {} - tsup@7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3))(typescript@5.3.3): + tsup@7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.4.5))(typescript@5.4.5): dependencies: bundle-require: 4.1.0(esbuild@0.19.12) cac: 6.7.14 @@ -20784,7 +20932,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.4.5)) resolve-from: 5.0.0 rollup: 4.17.2 source-map: 0.8.0-beta.0 @@ -20793,12 +20941,12 @@ snapshots: optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.2) postcss: 8.4.38 - typescript: 5.3.3 + typescript: 5.4.5 transitivePeerDependencies: - supports-color - ts-node - tsup@7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5))(typescript@5.4.5): + tsup@8.0.2(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3))(typescript@5.3.3): dependencies: bundle-require: 4.1.0(esbuild@0.19.12) cac: 6.7.14 @@ -20808,7 +20956,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.3.3)) resolve-from: 5.0.0 rollup: 4.17.2 source-map: 0.8.0-beta.0 @@ -20817,12 +20965,12 @@ snapshots: optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.2) postcss: 8.4.38 - typescript: 5.4.5 + typescript: 5.3.3 transitivePeerDependencies: - supports-color - ts-node - tsup@7.3.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.4.5))(typescript@5.4.5): + tsup@8.0.2(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5))(typescript@5.4.5): dependencies: bundle-require: 4.1.0(esbuild@0.19.12) cac: 6.7.14 @@ -20832,7 +20980,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.4.5)) + postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.19.33)(typescript@5.4.5)) resolve-from: 5.0.0 rollup: 4.17.2 source-map: 0.8.0-beta.0