From 7656965d9b979371d8648d8dd3c52114af45219d Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Fri, 25 Oct 2024 21:20:15 -0500 Subject: [PATCH] feat(chat-ui): add smart apply to chat panel (#3112) * feat(chat-ui): add smart apply functionality to chat panel * to: smart apply in editor also pass language id a * feat(tabby-agent): adding providing best fit range for smart apply function add provideSmartApplyLineRange functionality to TabbyAgent and ChatEditProvider a * to(vscode): request fit line range add ChatFeature to ChatViewProvider for smart apply functionality * feat: add onSmartApplyInEditor function to ChatSideBar component * to(tabby-agent): adding provideSmartApplyRequest base on chatEdit * feat(vscode): add smart apply functionality to ChatViewProvider * feat: remove onSmartApplyInEditor from ChatPanel and ChatSideBar components, adding smart bool to on apply edit * feat(tabby-agent): add smart apply functionality to generate-smart-apply prompt * refactor: Update parameter options in TabbyAgent.ts to include lineRange.undefined * feat: add support for indentInfo in SmartApplyCodeParams.undefined * docs: update smart apply instructions with indentation details.undefined * fix(tabby-agent): update documentation and remove unused imports fix(vscode): correct comment for line number range in ChatViewProviderundefined * chore: remove unnecessary prompt filesundefined * fix(lsp): add cancellation token handling for ChatLineRangeSmartApplyRequestundefined * rebase: rebase chat web view client api to webviewhelper also adding default data config fix: remove test * fix(ui): add support for wrapping long lines in CodeBlock component * refactor: update chatPanelViewProvider constructor parameters in Commands.ts and extension.ts files. * refactor: Update imports and remove unused code in chat inline edit feature. * feat: add fuzzyApplyRange function for applying ranges with fuzzy matching. * refactor(Commands): Modify the creation of ChatPanelViewProvider to include additional parameters. * refactor(chat): add SmartApplyFeature to the chat functionality. - move some chat status to chat global - move smart apply function to smart apply feature - update prompt * refactor(chat): remove smart apply code * refactor(vscode): remove provide line range method, also update new protocol params * refactor: rename fuzzyApplyRange.ts to SmartRange.ts and update functions, also remove indent param * refactor(chat): update SmartApplyFeature to handle apply range with fuzzy matching * refactor: update chat feature to include revealing editor range functionality. * refactor(client): remove global chat status * refactor(vscode): separate the workspace lsp server request from the chat feature * chore: remove unused logger * refactor(smart-apply): still using diff llms to apply code * docs: add comments for explaining the return value of getSmartApplyRange function. * refactor(chat): refactor SmartApply.ts with revealEditorRange function and logger updates. * chore: update code insertion guidelines. * chore: remove RevealEditorRangeRequest and using existing LSP API * chore: move smartApply to single section * chore: remove unused dynamic feature interface * chore: remove lsp client ShowDocRequest implementation * chore: adding rule avoid generate \n for first line for smart apply prompt * fix: update code style for pr 3112. * fix(vscode): update smart apply api. --------- Co-authored-by: Zhiming Ma --- clients/tabby-agent/package.json | 2 + clients/tabby-agent/src/chat/inlineEdit.ts | 370 ++---------------- .../src/chat/prompts/generate-smart-apply.md | 30 ++ .../prompts/provide-smart-apply-line-range.md | 66 ++++ clients/tabby-agent/src/chat/smartApply.ts | 330 ++++++++++++++++ clients/tabby-agent/src/chat/smartRange.ts | 45 +++ clients/tabby-agent/src/chat/utils.ts | 365 +++++++++++++++++ clients/tabby-agent/src/config/default.ts | 9 +- clients/tabby-agent/src/config/type.d.ts | 6 + clients/tabby-agent/src/protocol.ts | 30 ++ clients/tabby-agent/src/server.ts | 3 + clients/tabby-chat-panel/package.json | 2 +- clients/tabby-chat-panel/src/index.ts | 2 +- clients/vscode/src/Commands.ts | 7 +- .../vscode/src/chat/ChatPanelViewProvider.ts | 4 +- .../vscode/src/chat/ChatSideViewProvider.ts | 4 +- clients/vscode/src/chat/WebviewHelper.ts | 73 +++- clients/vscode/src/extension.ts | 3 +- clients/vscode/src/lsp/ChatFeature.ts | 93 +---- clients/vscode/src/lsp/Client.ts | 4 + clients/vscode/src/lsp/WorkspaceFeature.ts | 109 ++++++ .../app/files/components/chat-side-bar.tsx | 2 +- ee/tabby-ui/components/chat/chat.tsx | 10 +- .../components/message-markdown/index.tsx | 10 +- ee/tabby-ui/components/ui/codeblock.tsx | 33 +- ee/tabby-ui/components/ui/icons.tsx | 8 + pnpm-lock.yaml | 142 ++++--- 27 files changed, 1268 insertions(+), 494 deletions(-) create mode 100644 clients/tabby-agent/src/chat/prompts/generate-smart-apply.md create mode 100644 clients/tabby-agent/src/chat/prompts/provide-smart-apply-line-range.md create mode 100644 clients/tabby-agent/src/chat/smartApply.ts create mode 100644 clients/tabby-agent/src/chat/smartRange.ts create mode 100644 clients/tabby-agent/src/chat/utils.ts create mode 100644 clients/vscode/src/lsp/WorkspaceFeature.ts diff --git a/clients/tabby-agent/package.json b/clients/tabby-agent/package.json index 9ae91a6bb202..e62dd87db891 100644 --- a/clients/tabby-agent/package.json +++ b/clients/tabby-agent/package.json @@ -42,6 +42,7 @@ "@types/fast-levenshtein": "^0.0.4", "@types/fs-extra": "^11.0.1", "@types/glob": "^7.2.0", + "@types/js-levenshtein": "^1.1.3", "@types/mocha": "^10.0.1", "@types/node": "18.x", "@types/object-hash": "^3.0.0", @@ -68,6 +69,7 @@ "file-stream-rotator": "^1.0.0", "fs-extra": "^11.1.1", "glob": "^7.2.0", + "js-levenshtein": "^1.1.6", "jwt-decode": "^3.1.2", "lru-cache": "^9.1.1", "mac-ca": "^2.0.3", diff --git a/clients/tabby-agent/src/chat/inlineEdit.ts b/clients/tabby-agent/src/chat/inlineEdit.ts index 4465f323ef91..3438d76de832 100644 --- a/clients/tabby-agent/src/chat/inlineEdit.ts +++ b/clients/tabby-agent/src/chat/inlineEdit.ts @@ -1,10 +1,9 @@ -import type { Range, Location, Connection, CancellationToken, WorkspaceEdit } from "vscode-languageserver"; +import type { Connection, CancellationToken } from "vscode-languageserver"; import type { TextDocument } from "vscode-languageserver-textdocument"; import type { TextDocuments } from "../lsp/textDocuments"; import type { Feature } from "../feature"; import type { Configurations } from "../config"; import type { TabbyApiClient } from "../http/tabbyApiClient"; -import type { Readable } from "readable-stream"; import { ChatEditToken, ChatEditRequest, @@ -17,26 +16,11 @@ import { ChatFeatureNotAvailableError, ChatEditDocumentTooLongError, ChatEditMutexError, - ApplyWorkspaceEditRequest, - ApplyWorkspaceEditParams, ServerCapabilities, } from "../protocol"; import cryptoRandomString from "crypto-random-string"; -import * as Diff from "diff"; import { isEmptyRange } from "../utils/range"; -import { isBlank } from "../utils/string"; - -export type Edit = { - id: ChatEditToken; - location: Location; - languageId: string; - originalText: string; - editedRange: Range; - editedText: string; - comments: string; - buffer: string; - state: "editing" | "stopped" | "completed"; -}; +import { applyWorkspaceEdit, readResponseStream, Edit } from "./utils"; export class ChatEditProvider implements Feature { private lspConnection: Connection | undefined = undefined; @@ -116,6 +100,9 @@ export class ChatEditProvider implements Feature { if (!document) { return null; } + if (!this.lspConnection) { + return null; + } if (!this.tabbyApiClient.isChatApiAvailable()) { throw { name: "ChatFeatureNotAvailableError", @@ -235,8 +222,15 @@ export class ChatEditProvider implements Feature { if (!readableStream) { return null; } - await this.readResponseStream( + await readResponseStream( readableStream, + this.lspConnection, + this.currentEdit, + this.mutexAbortController, + () => { + this.currentEdit = undefined; + this.mutexAbortController = undefined; + }, config.chat.edit.responseDocumentTag, config.chat.edit.responseCommentTag, ); @@ -260,6 +254,10 @@ export class ChatEditProvider implements Feature { return false; } + if (!this.lspConnection) { + return false; + } + let markers; let line = params.location.range.start.line; for (; line < document.lineCount; line++) { @@ -309,337 +307,25 @@ export class ChatEditProvider implements Feature { } }); - await this.applyWorkspaceEdit({ - edit: { - changes: { - [params.location.uri]: [ - { - range: previewRange, - newText: lines.join("\n") + "\n", - }, - ], - }, - }, - options: { - undoStopBefore: false, - undoStopAfter: false, - }, - }); - return true; - } - - private async readResponseStream( - stream: Readable, - responseDocumentTag: string[], - responseCommentTag?: string[], - ): Promise { - const applyEdit = async (edit: Edit, isFirst: boolean = false, isLast: boolean = false) => { - if (isFirst) { - const workspaceEdit: WorkspaceEdit = { + await applyWorkspaceEdit( + { + edit: { changes: { - [edit.location.uri]: [ + [params.location.uri]: [ { - range: { - start: { line: edit.editedRange.start.line, character: 0 }, - end: { line: edit.editedRange.start.line, character: 0 }, - }, - newText: `<<<<<<< ${edit.id}\n`, + range: previewRange, + newText: lines.join("\n") + "\n", }, ], }, - }; - - await this.applyWorkspaceEdit({ - edit: workspaceEdit, - options: { - undoStopBefore: true, - undoStopAfter: false, - }, - }); - - edit.editedRange = { - start: { line: edit.editedRange.start.line + 1, character: 0 }, - end: { line: edit.editedRange.end.line + 1, character: 0 }, - }; - } - - const editedLines = this.generateChangesPreview(edit); - const workspaceEdit: WorkspaceEdit = { - changes: { - [edit.location.uri]: [ - { - range: edit.editedRange, - newText: editedLines.join("\n") + "\n", - }, - ], }, - }; - - await this.applyWorkspaceEdit({ - edit: workspaceEdit, options: { undoStopBefore: false, - undoStopAfter: isLast, + undoStopAfter: false, }, - }); - - edit.editedRange = { - start: { line: edit.editedRange.start.line, character: 0 }, - end: { line: edit.editedRange.start.line + editedLines.length, character: 0 }, - }; - }; - - const processBuffer = (edit: Edit, inTag: "document" | "comment", openTag: string, closeTag: string) => { - if (edit.buffer.startsWith(openTag)) { - edit.buffer = edit.buffer.substring(openTag.length); - } - - const reg = this.createCloseTagMatcher(closeTag); - const match = reg.exec(edit.buffer); - if (!match) { - edit[inTag === "document" ? "editedText" : "comments"] += edit.buffer; - edit.buffer = ""; - } else { - edit[inTag === "document" ? "editedText" : "comments"] += edit.buffer.substring(0, match.index); - edit.buffer = edit.buffer.substring(match.index); - return match[0] === closeTag ? false : inTag; - } - return inTag; - }; - const findOpenTag = ( - buffer: string, - responseDocumentTag: string[], - responseCommentTag?: string[], - ): "document" | "comment" | false => { - const openTags = [responseDocumentTag[0], responseCommentTag?.[0]].filter(Boolean); - if (openTags.length < 1) return false; - - const reg = new RegExp(openTags.join("|"), "g"); - const match = reg.exec(buffer); - if (match && match[0]) { - if (match[0] === responseDocumentTag[0]) { - return "document"; - } else if (match[0] === responseCommentTag?.[0]) { - return "comment"; - } - } - return false; - }; - - try { - if (!this.currentEdit) { - throw new Error("No current edit"); - } - - let inTag: "document" | "comment" | false = false; - - // Insert the first line as early as possible so codelens can be shown - await applyEdit(this.currentEdit, true, false); - - for await (const item of stream) { - if (!this.mutexAbortController || this.mutexAbortController.signal.aborted) { - break; - } - const delta = typeof item === "string" ? item : ""; - const edit = this.currentEdit; - edit.buffer += delta; - - if (!inTag) { - inTag = findOpenTag(edit.buffer, responseDocumentTag, responseCommentTag); - } - - if (inTag) { - const openTag = inTag === "document" ? responseDocumentTag[0] : responseCommentTag?.[0]; - const closeTag = inTag === "document" ? responseDocumentTag[1] : responseCommentTag?.[1]; - if (!closeTag || !openTag) break; - inTag = processBuffer(edit, inTag, openTag, closeTag); - if (delta.includes("\n")) { - await applyEdit(edit, false, false); - } - } - } - - if (this.currentEdit) { - this.currentEdit.state = "completed"; - await applyEdit(this.currentEdit, false, true); - } - } catch (error) { - if (this.currentEdit) { - this.currentEdit.state = "stopped"; - await applyEdit(this.currentEdit, false, true); - } - if (!(error instanceof TypeError && error.message.startsWith("terminated"))) { - throw error; - } - } finally { - this.currentEdit = undefined; - this.mutexAbortController = undefined; - } - } - - private async applyWorkspaceEdit(params: ApplyWorkspaceEditParams): Promise { - const lspConnection = this.lspConnection; - if (!lspConnection) { - return false; - } - try { - // FIXME(Sma1lboy): adding client capabilities to indicate if client support this method rather than try-catch - const result = await lspConnection.sendRequest(ApplyWorkspaceEditRequest.type, params); - return result; - } catch (error) { - try { - await lspConnection.workspace.applyEdit({ - edit: params.edit, - label: params.label, - }); - return true; - } catch (fallbackError) { - return false; - } - } - } - - // header line - // <<<<<<< Editing by Tabby <.#=+-> - // markers: - // [<] header - // [#] comments - // [.] waiting - // [|] in progress - // [=] unchanged - // [+] inserted - // [-] deleted - // [>] footer - // [x] stopped - // footer line - // >>>>>>> End of changes - private generateChangesPreview(edit: Edit): string[] { - const lines: string[] = []; - let markers = ""; - // lines.push(`<<<<<<< ${stateDescription} {{markers}}[${edit.id}]`); - markers += "["; - // comments: split by new line or 80 chars - const commentLines = edit.comments - .trim() - .split(/\n|(.{1,80})(?:\s|$)/g) - .filter((input) => !isBlank(input)); - const commentPrefix = this.getCommentPrefix(edit.languageId); - for (const line of commentLines) { - lines.push(commentPrefix + line); - markers += "#"; - } - const pushDiffValue = (diffValue: string, marker: string) => { - diffValue - .replace(/\n$/, "") - .split("\n") - .forEach((line) => { - lines.push(line); - markers += marker; - }); - }; - // diffs - const diffs = Diff.diffLines(edit.originalText, edit.editedText); - if (edit.state === "completed") { - diffs.forEach((diff) => { - if (diff.added) { - pushDiffValue(diff.value, "+"); - } else if (diff.removed) { - pushDiffValue(diff.value, "-"); - } else { - pushDiffValue(diff.value, "="); - } - }); - } else { - let inProgressChunk = 0; - const lastDiff = diffs[diffs.length - 1]; - if (lastDiff && lastDiff.added) { - inProgressChunk = 1; - } - let waitingChunks = 0; - for (let i = diffs.length - inProgressChunk - 1; i >= 0; i--) { - if (diffs[i]?.removed) { - waitingChunks++; - } else { - break; - } - } - let lineIndex = 0; - while (lineIndex < diffs.length - inProgressChunk - waitingChunks) { - const diff = diffs[lineIndex]; - if (!diff) { - break; - } - if (diff.added) { - pushDiffValue(diff.value, "+"); - } else if (diff.removed) { - pushDiffValue(diff.value, "-"); - } else { - pushDiffValue(diff.value, "="); - } - lineIndex++; - } - if (inProgressChunk && lastDiff) { - if (edit.state === "stopped") { - pushDiffValue(lastDiff.value, "x"); - } else { - pushDiffValue(lastDiff.value, "|"); - } - } - while (lineIndex < diffs.length - inProgressChunk) { - const diff = diffs[lineIndex]; - if (!diff) { - break; - } - if (edit.state === "stopped") { - pushDiffValue(diff.value, "x"); - } else { - pushDiffValue(diff.value, "."); - } - lineIndex++; - } - } - // footer - lines.push(`>>>>>>> ${edit.id} {{markers}}`); - markers += "]"; - // replace markers - // lines[0] = lines[0]!.replace("{{markers}}", markers); - lines[lines.length - 1] = lines[lines.length - 1]!.replace("{{markers}}", markers); - return lines; - } - - private createCloseTagMatcher(tag: string): RegExp { - let reg = `${tag}`; - for (let length = tag.length - 1; length > 0; length--) { - reg += "|" + tag.substring(0, length) + "$"; - } - return new RegExp(reg, "g"); - } - - // FIXME: improve this - private getCommentPrefix(languageId: string) { - if (["plaintext", "markdown"].includes(languageId)) { - return ""; - } - if (["python", "ruby"].includes(languageId)) { - return "#"; - } - if ( - [ - "c", - "cpp", - "java", - "javascript", - "typescript", - "javascriptreact", - "typescriptreact", - "go", - "rust", - "swift", - "kotlin", - ].includes(languageId) - ) { - return "//"; - } - return ""; + }, + this.lspConnection, + ); + return true; } } diff --git a/clients/tabby-agent/src/chat/prompts/generate-smart-apply.md b/clients/tabby-agent/src/chat/prompts/generate-smart-apply.md new file mode 100644 index 000000000000..172886cb6da0 --- /dev/null +++ b/clients/tabby-agent/src/chat/prompts/generate-smart-apply.md @@ -0,0 +1,30 @@ +You are an AI code insertion assistant. Your task is to accurately insert provided code into an existing document. Follow these guidelines: + +1. Analyze the code in `` and `` to determine the differences and appropriate insertion points. + +2. Insert only new or modified code from `` into ``. Do not duplicate existing code. + +3. When inserting new code: + a) Maintain the indentation style and level of the surrounding code. + b) Ensure the inserted code is parallel to, not inappropriately nested within, other code structures. + c) If unclear, insert after variable declarations, before main logic, or after related code blocks. + +4. For comments or minor additions: + a) Insert new comments or small code changes directly after the corresponding lines in the document. + b) Preserve the original structure and formatting of the existing code. + +5. Do not modify any existing code outside of the insertion process. + +6. Preserve the syntactical structure and formatting of both existing and inserted code, including comments and multi-line strings. + +7. Wrap the entire updated code, including both existing and newly inserted code, within `` XML tags. + +8. Do not include any explanations or Markdown formatting in the output. + +The opening tag and the first line of code must be on the same line +Example format: +first line of code +middle lines with normal formatting + +{{document}} +{{code}} diff --git a/clients/tabby-agent/src/chat/prompts/provide-smart-apply-line-range.md b/clients/tabby-agent/src/chat/prompts/provide-smart-apply-line-range.md new file mode 100644 index 000000000000..a602c20255d2 --- /dev/null +++ b/clients/tabby-agent/src/chat/prompts/provide-smart-apply-line-range.md @@ -0,0 +1,66 @@ +You are an AI assistant specialized in determining the most appropriate location to insert new code into an existing file. Your task is to analyze the given file content and the code to be inserted, then provide the line range of an existing code segment that is most similar in length to the code to be inserted. + +The file content is provided line by line, with each line in the format: +line number | code + +The new code to be inserted is provided in XML tags. + +Your task: +1. Analyze the existing code structure and the new code to be inserted. +2. Find a continuous segment of existing code that is most similar in length to the new code. +3. Provide ONLY the line range of this similar-length segment. + +You must reply with ONLY the suggested range in the format startLine-endLine, enclosed in XML tags. + +Important notes: +- The line numbers provided are one-based (starting from 1). +- Both startLine and endLine are inclusive (closed interval) +- The range should encompass a continuous segment of existing code similar in length to the new code. + +For example, if a 3-line code segment similar in length to the new code is found at lines 10-12, your response should be: +10-12 + +1. XML tags indicate the example code document. +2. XML tags indicate the example code to be applied. + +Examples: + +13 | target.trace(tagMessage(message), ...args); +14 | }; +15 | } +16 | if (method === "debug") { +17 | return (message: string, ...args: unknown[]) => { +18 | target.debug(tagMessage(message), ...args); +19 | }; +20 | } +21 | if (method === "info") { +22 | return (message: string, ...args: unknown[]) => { +23 | target.info(tagMessage(message), ...args); +24 | }; +25 | } + + + +if (method === "add") { + return (message: string, ...args: unknown[]) => { + target.error(tagMessage(message), ...args); + }; +} + + +1. If a 4-line segment similar to the apply code is found at lines 16-19, return: 16-19 +2. If a 4-line segment similar to the apply code is found at lines 21-24, return: 21-24 + +Do not include any explanation, existing code, or the code to be inserted in your response. + +File content: + +{{document}} + + +Code to be inserted: + +{{applyCode}} + + +Provide only the appropriate range of a similar-length code segment, remembering that line numbers are one-based, and both startLine and endLine are inclusive (closed interval). \ No newline at end of file diff --git a/clients/tabby-agent/src/chat/smartApply.ts b/clients/tabby-agent/src/chat/smartApply.ts new file mode 100644 index 000000000000..5947c9255e8c --- /dev/null +++ b/clients/tabby-agent/src/chat/smartApply.ts @@ -0,0 +1,330 @@ +import { TextDocument } from "vscode-languageserver-textdocument"; +import { + CancellationToken, + Connection, + Location, + Range, + ShowDocumentParams, + TextDocuments, +} from "vscode-languageserver"; +import type { Feature } from "../feature"; +import { + ChatEditDocumentTooLongError, + ChatEditMutexError, + ChatFeatureNotAvailableError, + ServerCapabilities, + SmartApplyRequest, + SmartApplyParams, +} from "../protocol"; +import { Configurations } from "../config"; +import { TabbyApiClient } from "../http/tabbyApiClient"; +import cryptoRandomString from "crypto-random-string"; +import { getLogger } from "../logger"; +import { readResponseStream, showDocument, Edit } from "./utils"; +import { getSmartApplyRange } from "./smartRange"; + +const logger = getLogger("SmartApplyFeature"); + +export class SmartApplyFeature implements Feature { + private lspConnection: Connection | undefined = undefined; + private mutexAbortController: AbortController | undefined = undefined; + constructor( + private readonly configurations: Configurations, + private readonly tabbyApiClient: TabbyApiClient, + private readonly documents: TextDocuments, + ) {} + + initialize(connection: Connection): ServerCapabilities | Promise { + this.lspConnection = connection; + connection.onRequest(SmartApplyRequest.type, async (params, token) => { + return this.provideSmartApplyEdit(params, token); + }); + + return {}; + } + initialized?(): void | Promise { + //nothing + } + shutdown?(): void | Promise { + //nothing + } + + async provideSmartApplyEdit(params: SmartApplyParams, token: CancellationToken): Promise { + logger.debug("Getting document"); + const document = this.documents.get(params.location.uri); + if (!document) { + logger.debug("Document not found, returning false"); + return false; + } + if (!this.lspConnection) { + logger.debug("LSP connection lost."); + return false; + } + + if (this.mutexAbortController && !this.mutexAbortController.signal.aborted) { + logger.warn("Another smart edit is already in progress"); + throw { + name: "ChatEditMutexError", + message: "Another smart edit is already in progress", + } as ChatEditMutexError; + } + this.mutexAbortController = new AbortController(); + logger.debug("mutex abort status: " + (this.mutexAbortController === undefined)); + token.onCancellationRequested(() => this.mutexAbortController?.abort()); + + let applyRange = getSmartApplyRange(document, params.text); + //if cannot find range, lets use backend LLMs + if (!applyRange) { + if (!this.tabbyApiClient.isChatApiAvailable) { + return false; + } + applyRange = await provideSmartApplyLineRange(document, params.text, this.tabbyApiClient, this.configurations); + } + + if (!applyRange) { + return false; + } + + try { + //reveal editor range + const revealEditorRangeParams: ShowDocumentParams = { + uri: params.location.uri, + selection: { + start: applyRange.range.start, + end: applyRange.range.end, + }, + takeFocus: true, + }; + await showDocument(revealEditorRangeParams, this.lspConnection); + } catch (error) { + logger.warn("cline not support reveal range"); + } + + try { + await provideSmartApplyEditLLM( + { + uri: params.location.uri, + range: { + start: applyRange.range.start, + end: { line: applyRange.range.end.line + 1, character: 0 }, + }, + }, + params.text, + applyRange.action === "insert" ? true : false, + document, + this.lspConnection, + this.tabbyApiClient, + this.configurations, + this.mutexAbortController, + () => { + this.mutexAbortController = undefined; + }, + ); + return true; + } catch (error) { + logger.error("Error applying smart edit:", error); + return false; + } finally { + logger.debug("Resetting mutex abort controller"); + this.mutexAbortController = undefined; + } + } +} + +async function provideSmartApplyLineRange( + document: TextDocument, + applyCodeBlock: string, + tabbyApiClient: TabbyApiClient, + configurations: Configurations, +): Promise<{ range: Range; action: "insert" | "replace" } | undefined> { + if (!document) { + return undefined; + } + if (!tabbyApiClient.isChatApiAvailable()) { + throw { + name: "ChatFeatureNotAvailableError", + message: "Chat feature not available", + } as ChatFeatureNotAvailableError; + } + + const documentText = document + .getText() + .split("\n") + .map((line, idx) => `${idx + 1} | ${line}`) + .join("\n"); + + const config = configurations.getMergedConfig(); + const promptTemplate = config.chat.smartApplyLineRange.promptTemplate; + + const messages: { role: "user"; content: string }[] = [ + { + role: "user", + content: promptTemplate.replace(/{{document}}|{{applyCode}}/g, (pattern: string) => { + switch (pattern) { + case "{{document}}": + return documentText; + case "{{applyCode}}": + return applyCodeBlock; + default: + return ""; + } + }), + }, + ]; + + try { + const readableStream = await tabbyApiClient.fetchChatStream({ + messages, + model: "", + stream: true, + }); + + if (!readableStream) { + return undefined; + } + + let response = ""; + for await (const chunk of readableStream) { + response += chunk; + } + + const regex = /(.*?)<\/GENERATEDCODE>/s; + const match = response.match(regex); + if (match && match[1]) { + response = match[1].trim(); + } + + const range = response.split("-"); + if (range.length !== 2) { + return undefined; + } + + const startLine = parseInt(range[0] ?? "0", 10) - 1; + const endLine = parseInt(range[1] ?? "0", 10) - 1; + + return { + range: { + start: { line: startLine < 0 ? 0 : startLine, character: 0 }, + end: { line: endLine < 0 ? 0 : endLine, character: Number.MAX_SAFE_INTEGER }, + }, + action: startLine == endLine ? "insert" : "replace", + }; + } catch (error) { + return undefined; + } +} + +async function provideSmartApplyEditLLM( + location: Location, + applyCode: string, + insertMode: boolean, + document: TextDocument, + lspConnection: Connection, + tabbyApiClient: TabbyApiClient, + configurations: Configurations, + mutexAbortController: AbortController, + onResetMutex: () => void, +): Promise { + if (!document) { + logger.warn("Document not found"); + return false; + } + if (!lspConnection) { + logger.warn("LSP connection failed"); + return false; + } + + if (!tabbyApiClient.isChatApiAvailable()) { + throw { + name: "ChatFeatureNotAvailableError", + message: "Chat feature not available", + } as ChatFeatureNotAvailableError; + } + + const config = configurations.getMergedConfig(); + const documentText = document.getText(); + const selection = { + start: document.offsetAt(location.range.start), + end: document.offsetAt(location.range.end), + }; + const selectedDocumentText = documentText.substring(selection.start, selection.end); + + logger.debug("current selectedDoc: " + selectedDocumentText); + + if (selection.end - selection.start > config.chat.edit.documentMaxChars) { + throw { name: "ChatEditDocumentTooLongError", message: "Document too long" } as ChatEditDocumentTooLongError; + } + + const promptTemplate = config.chat.smartApply.promptTemplate; + + // Extract the selected text and the surrounding context + let documentPrefix = documentText.substring(0, selection.start); + let documentSuffix = documentText.substring(selection.end); + if (documentText.length > config.chat.edit.documentMaxChars) { + const charsRemain = config.chat.edit.documentMaxChars - selectedDocumentText.length; + if (documentPrefix.length < charsRemain / 2) { + documentSuffix = documentSuffix.substring(0, charsRemain - documentPrefix.length); + } else if (documentSuffix.length < charsRemain / 2) { + documentPrefix = documentPrefix.substring(documentPrefix.length - charsRemain + documentSuffix.length); + } else { + documentPrefix = documentPrefix.substring(documentPrefix.length - charsRemain / 2); + documentSuffix = documentSuffix.substring(0, charsRemain / 2); + } + } + + const messages: { role: "user"; content: string }[] = [ + { + role: "user", + content: promptTemplate.replace(/{{document}}|{{code}}/g, (pattern: string) => { + switch (pattern) { + case "{{document}}": + return selectedDocumentText; + case "{{code}}": + return applyCode || ""; + default: + return ""; + } + }), + }, + ]; + + try { + const readableStream = await tabbyApiClient.fetchChatStream({ + messages, + model: "", + stream: true, + }); + + if (!readableStream) { + return false; + } + const editId = "tabby-" + cryptoRandomString({ length: 6, type: "alphanumeric" }); + const currentEdit: Edit = { + id: editId, + location: location, + languageId: document.languageId, + originalText: selectedDocumentText, + editedRange: insertMode + ? { start: location.range.start, end: location.range.end } + : { start: location.range.start, end: location.range.end }, + editedText: "", + comments: "", + buffer: "", + state: "editing", + }; + + await readResponseStream( + readableStream, + lspConnection, + currentEdit, + mutexAbortController, + onResetMutex, + config.chat.edit.responseDocumentTag, + config.chat.edit.responseCommentTag, + ); + + return true; + } catch (error) { + return false; + } +} diff --git a/clients/tabby-agent/src/chat/smartRange.ts b/clients/tabby-agent/src/chat/smartRange.ts new file mode 100644 index 000000000000..8471bf75b8b5 --- /dev/null +++ b/clients/tabby-agent/src/chat/smartRange.ts @@ -0,0 +1,45 @@ +import levenshtein from "js-levenshtein"; +import { Position, Range } from "vscode-languageserver-protocol"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +//return [start, end] close interval 0-based range +export function getSmartApplyRange( + document: TextDocument, + snippet: string, +): { range: Range; action: "insert" | "replace" } | undefined { + const applyRange = fuzzyApplyRange(document, snippet); + if (!applyRange) { + return undefined; + } + //insert mode + if (applyRange.range.start.line === applyRange.range.end.line || document.getText().trim() === "") { + return { range: applyRange.range, action: "insert" }; + } + return { range: applyRange.range, action: "replace" }; +} + +function fuzzyApplyRange(document: TextDocument, snippet: string): { range: Range; score: number } | null { + const lines = document.getText().split("\n"); + const snippetLines = snippet.split("\n"); + + let [minDistance, index] = [Number.MAX_SAFE_INTEGER, 0]; + for (let i = 0; i <= lines.length - snippetLines.length; i++) { + const window = lines.slice(i, i + snippetLines.length).join("\n"); + const distance = levenshtein(window, snippet); + if (minDistance >= distance) { + minDistance = distance; + index = i; + } + } + + if (minDistance === Number.MAX_SAFE_INTEGER && index === 0) { + return null; + } + + const startLine = index; + const endLine = index + snippetLines.length - 1; + const start: Position = { line: startLine, character: 0 }; + const end: Position = { line: endLine, character: lines[endLine]?.length || 0 }; + + return { range: { start, end }, score: minDistance }; +} diff --git a/clients/tabby-agent/src/chat/utils.ts b/clients/tabby-agent/src/chat/utils.ts new file mode 100644 index 000000000000..4fedbbd4006c --- /dev/null +++ b/clients/tabby-agent/src/chat/utils.ts @@ -0,0 +1,365 @@ +//chat related utils functions + +import { Readable } from "stream"; +import { + Range, + Location, + ShowDocumentParams, + ShowDocumentRequest, + WorkspaceEdit, +} from "vscode-languageserver-protocol"; +import { Connection } from "vscode-languageserver"; +import * as Diff from "diff"; +import { ApplyWorkspaceEditParams, ApplyWorkspaceEditRequest } from "../protocol"; +import { isBlank } from "../utils/string"; + +export type Edit = { + id: string; + location: Location; + languageId: string; + originalText: string; + editedRange: Range; + editedText: string; + comments: string; + buffer: string; + state: "editing" | "stopped" | "completed"; +}; + +export async function readResponseStream( + stream: Readable, + connection: Connection, + currentEdit: Edit | undefined, + mutexAbortController: AbortController | undefined, + resetEditAndMutexAbortController: () => void, + responseDocumentTag: string[], + responseCommentTag?: string[], +): Promise { + const applyEdit = async (edit: Edit, isFirst: boolean = false, isLast: boolean = false) => { + if (isFirst) { + const workspaceEdit: WorkspaceEdit = { + changes: { + [edit.location.uri]: [ + { + range: { + start: { line: edit.editedRange.start.line, character: 0 }, + end: { line: edit.editedRange.start.line, character: 0 }, + }, + newText: `<<<<<<< ${edit.id}\n`, + }, + ], + }, + }; + + await applyWorkspaceEdit( + { + edit: workspaceEdit, + options: { + undoStopBefore: true, + undoStopAfter: false, + }, + }, + connection, + ); + + edit.editedRange = { + start: { line: edit.editedRange.start.line + 1, character: 0 }, + end: { line: edit.editedRange.end.line + 1, character: 0 }, + }; + } + + const editedLines = generateChangesPreview(edit); + const workspaceEdit: WorkspaceEdit = { + changes: { + [edit.location.uri]: [ + { + range: edit.editedRange, + newText: editedLines.join("\n") + "\n", + }, + ], + }, + }; + + await applyWorkspaceEdit( + { + edit: workspaceEdit, + options: { + undoStopBefore: false, + undoStopAfter: isLast, + }, + }, + connection, + ); + + edit.editedRange = { + start: { line: edit.editedRange.start.line, character: 0 }, + end: { line: edit.editedRange.start.line + editedLines.length, character: 0 }, + }; + }; + + const processBuffer = (edit: Edit, inTag: "document" | "comment", openTag: string, closeTag: string) => { + if (edit.buffer.startsWith(openTag)) { + edit.buffer = edit.buffer.substring(openTag.length); + } + + const reg = createCloseTagMatcher(closeTag); + const match = reg.exec(edit.buffer); + if (!match) { + edit[inTag === "document" ? "editedText" : "comments"] += edit.buffer; + edit.buffer = ""; + } else { + edit[inTag === "document" ? "editedText" : "comments"] += edit.buffer.substring(0, match.index); + edit.buffer = edit.buffer.substring(match.index); + return match[0] === closeTag ? false : inTag; + } + return inTag; + }; + const findOpenTag = ( + buffer: string, + responseDocumentTag: string[], + responseCommentTag?: string[], + ): "document" | "comment" | false => { + const openTags = [responseDocumentTag[0], responseCommentTag?.[0]].filter(Boolean); + if (openTags.length < 1) return false; + + const reg = new RegExp(openTags.join("|"), "g"); + const match = reg.exec(buffer); + if (match && match[0]) { + if (match[0] === responseDocumentTag[0]) { + return "document"; + } else if (match[0] === responseCommentTag?.[0]) { + return "comment"; + } + } + return false; + }; + + try { + if (!currentEdit) { + throw new Error("No current edit"); + } + + let inTag: "document" | "comment" | false = false; + + // Insert the first line as early as possible so codelens can be shown + await applyEdit(currentEdit, true, false); + + for await (const item of stream) { + if (!mutexAbortController || mutexAbortController.signal.aborted) { + break; + } + const delta = typeof item === "string" ? item : ""; + const edit = currentEdit; + edit.buffer += delta; + + if (!inTag) { + inTag = findOpenTag(edit.buffer, responseDocumentTag, responseCommentTag); + } + + if (inTag) { + const openTag = inTag === "document" ? responseDocumentTag[0] : responseCommentTag?.[0]; + const closeTag = inTag === "document" ? responseDocumentTag[1] : responseCommentTag?.[1]; + if (!closeTag || !openTag) break; + inTag = processBuffer(edit, inTag, openTag, closeTag); + if (delta.includes("\n")) { + await applyEdit(edit, false, false); + } + } + } + + if (currentEdit) { + currentEdit.state = "completed"; + await applyEdit(currentEdit, false, true); + } + } catch (error) { + if (currentEdit) { + currentEdit.state = "stopped"; + await applyEdit(currentEdit, false, true); + } + if (!(error instanceof TypeError && error.message.startsWith("terminated"))) { + throw error; + } + } finally { + resetEditAndMutexAbortController(); + } +} + +export async function applyWorkspaceEdit( + params: ApplyWorkspaceEditParams, + lspConnection: Connection, +): Promise { + if (!lspConnection) { + return false; + } + try { + // FIXME(Sma1lboy): adding client capabilities to indicate if client support this method rather than try-catch + const result = await lspConnection.sendRequest(ApplyWorkspaceEditRequest.type, params); + return result; + } catch (error) { + try { + await lspConnection.workspace.applyEdit({ + edit: params.edit, + label: params.label, + }); + return true; + } catch (fallbackError) { + return false; + } + } +} + +export async function showDocument(params: ShowDocumentParams, lspConnection: Connection): Promise { + if (!lspConnection) { + return false; + } + + try { + const result = await lspConnection.sendRequest(ShowDocumentRequest.type, params); + return result.success; + } catch (error) { + return false; + } +} + +// header line +// <<<<<<< Editing by Tabby <.#=+-> +// markers: +// [<] header +// [#] comments +// [.] waiting +// [|] in progress +// [=] unchanged +// [+] inserted +// [-] deleted +// [>] footer +// [x] stopped +// footer line +// >>>>>>> End of changes +export function generateChangesPreview(edit: Edit): string[] { + const lines: string[] = []; + let markers = ""; + // lines.push(`<<<<<<< ${stateDescription} {{markers}}[${edit.id}]`); + markers += "["; + // comments: split by new line or 80 chars + const commentLines = edit.comments + .trim() + .split(/\n|(.{1,80})(?:\s|$)/g) + .filter((input) => !isBlank(input)); + const commentPrefix = getCommentPrefix(edit.languageId); + for (const line of commentLines) { + lines.push(commentPrefix + line); + markers += "#"; + } + const pushDiffValue = (diffValue: string, marker: string) => { + diffValue + .replace(/\n$/, "") + .split("\n") + .forEach((line) => { + lines.push(line); + markers += marker; + }); + }; + // diffs + const diffs = Diff.diffLines(edit.originalText, edit.editedText); + if (edit.state === "completed") { + diffs.forEach((diff) => { + if (diff.added) { + pushDiffValue(diff.value, "+"); + } else if (diff.removed) { + pushDiffValue(diff.value, "-"); + } else { + pushDiffValue(diff.value, "="); + } + }); + } else { + let inProgressChunk = 0; + const lastDiff = diffs[diffs.length - 1]; + if (lastDiff && lastDiff.added) { + inProgressChunk = 1; + } + let waitingChunks = 0; + for (let i = diffs.length - inProgressChunk - 1; i >= 0; i--) { + if (diffs[i]?.removed) { + waitingChunks++; + } else { + break; + } + } + let lineIndex = 0; + while (lineIndex < diffs.length - inProgressChunk - waitingChunks) { + const diff = diffs[lineIndex]; + if (!diff) { + break; + } + if (diff.added) { + pushDiffValue(diff.value, "+"); + } else if (diff.removed) { + pushDiffValue(diff.value, "-"); + } else { + pushDiffValue(diff.value, "="); + } + lineIndex++; + } + if (inProgressChunk && lastDiff) { + if (edit.state === "stopped") { + pushDiffValue(lastDiff.value, "x"); + } else { + pushDiffValue(lastDiff.value, "|"); + } + } + while (lineIndex < diffs.length - inProgressChunk) { + const diff = diffs[lineIndex]; + if (!diff) { + break; + } + if (edit.state === "stopped") { + pushDiffValue(diff.value, "x"); + } else { + pushDiffValue(diff.value, "."); + } + lineIndex++; + } + } + // footer + lines.push(`>>>>>>> ${edit.id} {{markers}}`); + markers += "]"; + // replace markers + // lines[0] = lines[0]!.replace("{{markers}}", markers); + lines[lines.length - 1] = lines[lines.length - 1]!.replace("{{markers}}", markers); + return lines; +} + +export function createCloseTagMatcher(tag: string): RegExp { + let reg = `${tag}`; + for (let length = tag.length - 1; length > 0; length--) { + reg += "|" + tag.substring(0, length) + "$"; + } + return new RegExp(reg, "g"); +} + +// FIXME: improve this +export function getCommentPrefix(languageId: string) { + if (["plaintext", "markdown"].includes(languageId)) { + return ""; + } + if (["python", "ruby"].includes(languageId)) { + return "#"; + } + if ( + [ + "c", + "cpp", + "java", + "javascript", + "typescript", + "javascriptreact", + "typescriptreact", + "go", + "rust", + "swift", + "kotlin", + ].includes(languageId) + ) { + return "//"; + } + return ""; +} diff --git a/clients/tabby-agent/src/config/default.ts b/clients/tabby-agent/src/config/default.ts index 0f17b22ea83e..79d3758f632f 100644 --- a/clients/tabby-agent/src/config/default.ts +++ b/clients/tabby-agent/src/config/default.ts @@ -4,7 +4,8 @@ import generateCommitMessagePrompt from "../chat/prompts/generate-commit-message import generateDocsPrompt from "../chat/prompts/generate-docs.md"; import editCommandReplacePrompt from "../chat/prompts/edit-command-replace.md"; import editCommandInsertPrompt from "../chat/prompts/edit-command-insert.md"; - +import generateSmartApplyPrompt from "../chat/prompts/generate-smart-apply.md"; +import provideSmartApplyLineRangePrompt from "../chat/prompts/provide-smart-apply-line-range.md"; export const defaultConfigData: ConfigData = { server: { endpoint: "http://localhost:8080", @@ -94,6 +95,12 @@ export const defaultConfigData: ConfigData = { responseMatcher: /(?<=(["'`]+)?\s*)(feat|fix|docs|refactor|style|test|build|ci|chore)(\(\S+\))?:.+(?=\s*\1)/gis.toString(), }, + smartApplyLineRange: { + promptTemplate: provideSmartApplyLineRangePrompt, + }, + smartApply: { + promptTemplate: generateSmartApplyPrompt, + }, }, logs: { level: "silent", diff --git a/clients/tabby-agent/src/config/type.d.ts b/clients/tabby-agent/src/config/type.d.ts index 7bf1712df0fa..c5028e85a7f4 100644 --- a/clients/tabby-agent/src/config/type.d.ts +++ b/clients/tabby-agent/src/config/type.d.ts @@ -99,6 +99,12 @@ export type ConfigData = { promptTemplate: string; responseMatcher: string; }; + smartApplyLineRange: { + promptTemplate: string; + }; + smartApply: { + promptTemplate: string; + }; }; logs: { // Controls the level of the logger written to the `~/.tabby-client/agent/logs/` diff --git a/clients/tabby-agent/src/protocol.ts b/clients/tabby-agent/src/protocol.ts index 2bebfa3f61fa..9ac4b96912dd 100644 --- a/clients/tabby-agent/src/protocol.ts +++ b/clients/tabby-agent/src/protocol.ts @@ -526,6 +526,36 @@ export type ChatEditResolveCommand = LspCommand & { arguments: [ChatEditResolveParams]; }; +/** + * [Tabby] Smart Apply Request(↩️) + * + * This method is sent from the client to the server to smart apply the text to the target location. + * The server will edit the document content using ApplyEdit(`workspace/applyEdit`) request, + * which requires the client to have this capability. + * - method: `tabby/chat/smartApply` + * - params: {@link SmartApplyParams} + * - result: boolean + * - error: {@link ChatFeatureNotAvailableError} + * | {@link ChatEditDocumentTooLongError} + * | {@link ChatEditMutexError} + */ +export namespace SmartApplyRequest { + export const method = "tabby/chat/smartApply"; + export const messageDirection = MessageDirection.clientToServer; + export const type = new ProtocolRequestType< + SmartApplyParams, + boolean, + void, + ChatFeatureNotAvailableError | ChatEditDocumentTooLongError | ChatEditMutexError, + void + >(method); +} + +export type SmartApplyParams = { + location: Location; + text: string; +}; + /** * [Tabby] Did Change Active Editor Notification(➡️) * diff --git a/clients/tabby-agent/src/server.ts b/clients/tabby-agent/src/server.ts index 4410938b3e0c..0acc20a01ffb 100644 --- a/clients/tabby-agent/src/server.ts +++ b/clients/tabby-agent/src/server.ts @@ -47,6 +47,7 @@ import { StatusProvider } from "./status"; import { CommandProvider } from "./command"; import { name as serverName, version as serverVersion } from "../package.json"; import "./utils/array"; +import { SmartApplyFeature } from "./chat/smartApply"; import { FileTracker } from "./codeSearch/fileTracker"; export class Server { @@ -87,6 +88,7 @@ export class Server { this.tabbyApiClient, this.gitContextProvider, ); + private readonly smartApplyFeature = new SmartApplyFeature(this.configurations, this.tabbyApiClient, this.documents); private readonly statusProvider = new StatusProvider(this.dataStore, this.configurations, this.tabbyApiClient); private readonly commandProvider = new CommandProvider(this.chatEditProvider, this.statusProvider); @@ -189,6 +191,7 @@ export class Server { this.chatFeature, this.chatEditProvider, this.commitMessageGenerator, + this.smartApplyFeature, this.statusProvider, this.commandProvider, this.fileTracker, diff --git a/clients/tabby-chat-panel/package.json b/clients/tabby-chat-panel/package.json index 2a8e6b54b04c..d07e9ea118dc 100644 --- a/clients/tabby-chat-panel/package.json +++ b/clients/tabby-chat-panel/package.json @@ -1,7 +1,7 @@ { "name": "tabby-chat-panel", "type": "module", - "version": "0.2.1", + "version": "0.2.2", "keywords": [], "sideEffects": false, "exports": { diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index 98c48e601be8..95dec07c4f2f 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -60,7 +60,7 @@ export interface ClientApi { onSubmitMessage: (msg: string, relevantContext?: Context[]) => Promise - onApplyInEditor: (content: string) => void + onApplyInEditor: (content: string, opts?: { languageId: string, smart: boolean }) => void // On current page is loaded. onLoaded: (params?: OnLoadedParams | undefined) => void diff --git a/clients/vscode/src/Commands.ts b/clients/vscode/src/Commands.ts index 2f1cb1121732..1cd252c6f44c 100644 --- a/clients/vscode/src/Commands.ts +++ b/clients/vscode/src/Commands.ts @@ -283,7 +283,12 @@ export class Commands { retainContextWhenHidden: true, }); - const chatPanelViewProvider = new ChatPanelViewProvider(this.context, this.client.agent, this.gitProvider); + const chatPanelViewProvider = new ChatPanelViewProvider( + this.context, + this.client.agent, + this.gitProvider, + this.client.chat, + ); chatPanelViewProvider.resolveWebviewView(panel); }, diff --git a/clients/vscode/src/chat/ChatPanelViewProvider.ts b/clients/vscode/src/chat/ChatPanelViewProvider.ts index 4ced79204a98..6df6082ac81d 100644 --- a/clients/vscode/src/chat/ChatPanelViewProvider.ts +++ b/clients/vscode/src/chat/ChatPanelViewProvider.ts @@ -3,6 +3,7 @@ import type { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; import { WebviewHelper } from "./WebviewHelper"; import type { AgentFeature as Agent } from "../lsp/AgentFeature"; import { GitProvider } from "../git/GitProvider"; +import { ChatFeature } from "../lsp/ChatFeature"; import { getLogger } from "../logger"; export class ChatPanelViewProvider { @@ -14,9 +15,10 @@ export class ChatPanelViewProvider { private readonly context: ExtensionContext, agent: Agent, gitProvider: GitProvider, + chat: ChatFeature, ) { const logger = getLogger(); - this.webviewHelper = new WebviewHelper(context, agent, logger, gitProvider); + this.webviewHelper = new WebviewHelper(context, agent, logger, gitProvider, chat); } static getFileContextFromSelection({ diff --git a/clients/vscode/src/chat/ChatSideViewProvider.ts b/clients/vscode/src/chat/ChatSideViewProvider.ts index cad14bbf53b9..a7081138d25f 100644 --- a/clients/vscode/src/chat/ChatSideViewProvider.ts +++ b/clients/vscode/src/chat/ChatSideViewProvider.ts @@ -3,6 +3,7 @@ import type { ServerApi, ChatMessage, Context } from "tabby-chat-panel"; import { WebviewHelper } from "./WebviewHelper"; import type { AgentFeature as Agent } from "../lsp/AgentFeature"; import { GitProvider } from "../git/GitProvider"; +import { ChatFeature } from "../lsp/ChatFeature"; export class ChatSideViewProvider implements WebviewViewProvider { webview?: WebviewView; client?: ServerApi; @@ -13,8 +14,9 @@ export class ChatSideViewProvider implements WebviewViewProvider { agent: Agent, logger: LogOutputChannel, gitProvider: GitProvider, + chat: ChatFeature, ) { - this.webviewHelper = new WebviewHelper(context, agent, logger, gitProvider); + this.webviewHelper = new WebviewHelper(context, agent, logger, gitProvider, chat); } static getFileContextFromSelection({ diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index 15d58061e343..473114d9049a 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -15,6 +15,7 @@ import { TextDocument, Webview, ColorThemeKind, + ProgressLocation, } from "vscode"; import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams } from "tabby-chat-panel"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; @@ -24,6 +25,7 @@ import type { ServerInfo } from "tabby-agent"; import type { AgentFeature as Agent } from "../lsp/AgentFeature"; import { GitProvider } from "../git/GitProvider"; import { createClient } from "./chatPanel"; +import { ChatFeature } from "../lsp/ChatFeature"; import { isBrowser } from "../env"; export class WebviewHelper { @@ -38,6 +40,7 @@ export class WebviewHelper { private readonly agent: Agent, private readonly logger: LogOutputChannel, private readonly gitProvider: GitProvider, + private readonly chat: ChatFeature | undefined, ) {} static getColorThemeString(kind: ColorThemeKind) { @@ -540,12 +543,8 @@ export class WebviewHelper { // FIXME: maybe deduplicate on chatMessage.relevantContext this.sendMessage(chatMessage); }, - onApplyInEditor: (content: string) => { - const editor = window.activeTextEditor; - if (editor) { - const document = editor.document; - const selection = editor.selection; - + onApplyInEditor: async (content: string, opts?: { languageId: string; smart: boolean }) => { + const getIndentInfo = (document: TextDocument, selection: Selection) => { // Determine the indentation for the content // The calculation is based solely on the indentation of the first line const lineText = document.lineAt(selection.start.line).text; @@ -560,13 +559,73 @@ export class WebviewHelper { const indentAmountForTheFirstLine = Math.max(indent.length - selection.start.character, 0); const indentForTheFirstLine = indentUnit?.repeat(indentAmountForTheFirstLine) || ""; + return { indent, indentForTheFirstLine }; + }; + + const applyInEditor = (editor: TextEditor) => { + const document = editor.document; + const selection = editor.selection; + const { indent, indentForTheFirstLine } = getIndentInfo(document, selection); // Indent the content const indentedContent = indentForTheFirstLine + content.replaceAll("\n", "\n" + indent); - // Apply into the editor editor.edit((editBuilder) => { editBuilder.replace(selection, indentedContent); }); + }; + const smartApplyInEditor = async (editor: TextEditor, opts: { languageId: string; smart: boolean }) => { + if (editor.document.languageId !== opts.languageId) { + this.logger.debug("Editor's languageId:", editor.document.languageId, "opts.languageId:", opts.languageId); + window.showInformationMessage("The active editor is not in the correct language. Did normal apply."); + applyInEditor(editor); + return; + } + + this.logger.info("Smart apply in editor started."); + this.logger.trace("Smart apply in editor with content:", { content }); + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Smart Apply in Progress", + cancellable: true, + }, + async (progress, token) => { + progress.report({ increment: 0, message: "Applying smart edit..." }); + try { + await this.chat?.provideSmartApplyEdit( + { + text: content, + location: { + uri: editor.document.uri.toString(), + range: { + start: { line: editor.selection.start.line, character: editor.selection.start.character }, + end: { line: editor.selection.end.line, character: editor.selection.end.character }, + }, + }, + }, + token, + ); + } catch (error) { + if (error instanceof Error) { + window.showErrorMessage(error.message); + } else { + window.showErrorMessage("An unknown error occurred"); + } + } + }, + ); + }; + + const editor = window.activeTextEditor; + if (!editor || this.chat === undefined) { + window.showErrorMessage("No active editor found."); + return; + } + if (!opts || !opts.smart) { + applyInEditor(editor); + } else { + smartApplyInEditor(editor, opts); } }, onLoaded: (params: OnLoadedParams | undefined) => { diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index b8601541af42..1fdb1bfb9901 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -78,13 +78,12 @@ export async function activate(context: ExtensionContext) { }); // Register chat panel - const chatViewProvider = new ChatSideViewProvider(context, client.agent, logger, gitProvider); + const chatViewProvider = new ChatSideViewProvider(context, client.agent, logger, gitProvider, client.chat); context.subscriptions.push( window.registerWebviewViewProvider("tabby.chatView", chatViewProvider, { webviewOptions: { retainContextWhenHidden: true }, }), ); - // Create chat panel view await gitProvider.init(); await client.start(); diff --git a/clients/vscode/src/lsp/ChatFeature.ts b/clients/vscode/src/lsp/ChatFeature.ts index 02aea32674f5..ce147eccb03f 100644 --- a/clients/vscode/src/lsp/ChatFeature.ts +++ b/clients/vscode/src/lsp/ChatFeature.ts @@ -1,15 +1,6 @@ import { EventEmitter } from "events"; -import { - window, - workspace, - Range, - Position, - Disposable, - CancellationToken, - TextEditorEdit, - TextDocument, -} from "vscode"; -import { BaseLanguageClient, DynamicFeature, FeatureState, RegistrationData, TextEdit } from "vscode-languageclient"; +import { Disposable, CancellationToken } from "vscode"; +import { BaseLanguageClient, DynamicFeature, FeatureState, RegistrationData } from "vscode-languageclient"; import { ServerCapabilities, ChatFeatureRegistration, @@ -24,10 +15,9 @@ import { ChatEditToken, ChatEditResolveRequest, ChatEditResolveParams, - ApplyWorkspaceEditParams, - ApplyWorkspaceEditRequest, + SmartApplyParams, + SmartApplyRequest, } from "tabby-agent"; -import { diffLines } from "diff"; export class ChatFeature extends EventEmitter implements DynamicFeature { private registration: string | undefined = undefined; @@ -59,12 +49,6 @@ export class ChatFeature extends EventEmitter implements DynamicFeature if (capabilities.tabby?.chat) { this.register({ id: this.registrationType.method, registerOptions: {} }); } - - this.disposables.push( - this.client.onRequest(ApplyWorkspaceEditRequest.type, (params: ApplyWorkspaceEditParams) => { - return this.handleApplyWorkspaceEdit(params); - }), - ); } register(data: RegistrationData): void { @@ -127,75 +111,14 @@ export class ChatFeature extends EventEmitter implements DynamicFeature return this.client.sendRequest(ChatEditRequest.method, params, token); } - private async handleApplyWorkspaceEdit(params: ApplyWorkspaceEditParams): Promise { - const { edit, options } = params; - const activeEditor = window.activeTextEditor; - if (!activeEditor) { - return false; - } - - try { - const success = await activeEditor.edit( - (editBuilder: TextEditorEdit) => { - Object.entries(edit.changes || {}).forEach(([uri, textEdits]) => { - const document = workspace.textDocuments.find((doc) => doc.uri.toString() === uri); - if (document && document === activeEditor.document) { - const textEdit = textEdits[0]; - if (textEdits.length === 1 && textEdit) { - applyTextEditMinimalLineChange(editBuilder, textEdit, document); - } else { - textEdits.forEach((textEdit) => { - const range = new Range( - new Position(textEdit.range.start.line, textEdit.range.start.character), - new Position(textEdit.range.end.line, textEdit.range.end.character), - ); - editBuilder.replace(range, textEdit.newText); - }); - } - } - }); - }, - { - undoStopBefore: options?.undoStopBefore ?? false, - undoStopAfter: options?.undoStopAfter ?? false, - }, - ); - - return success; - } catch (error) { - return false; + async provideSmartApplyEdit(params: SmartApplyParams, token?: CancellationToken): Promise { + if (!this.isAvailable) { + return null; } + return this.client.sendRequest(SmartApplyRequest.method, params, token); } async resolveEdit(params: ChatEditResolveParams): Promise { return this.client.sendRequest(ChatEditResolveRequest.method, params); } } - -function applyTextEditMinimalLineChange(editBuilder: TextEditorEdit, textEdit: TextEdit, document: TextDocument) { - const documentRange = new Range( - new Position(textEdit.range.start.line, textEdit.range.start.character), - new Position(textEdit.range.end.line, textEdit.range.end.character), - ); - - const text = document.getText(documentRange); - const newText = textEdit.newText; - const diffs = diffLines(text, newText); - - let line = documentRange.start.line; - for (const diff of diffs) { - if (!diff.count) { - continue; - } - - if (diff.added) { - editBuilder.insert(new Position(line, 0), diff.value); - } else if (diff.removed) { - const range = new Range(new Position(line + 0, 0), new Position(line + diff.count, 0)); - editBuilder.delete(range); - line += diff.count; - } else { - line += diff.count; - } - } -} diff --git a/clients/vscode/src/lsp/Client.ts b/clients/vscode/src/lsp/Client.ts index d22f60b7912a..177a5966c0e8 100644 --- a/clients/vscode/src/lsp/Client.ts +++ b/clients/vscode/src/lsp/Client.ts @@ -17,6 +17,7 @@ import { Config } from "../Config"; import { InlineCompletionProvider } from "../InlineCompletionProvider"; import { GitProvider } from "../git/GitProvider"; import { getLogger } from "../logger"; +import { WorkSpaceFeature } from "./WorkspaceFeature"; import { FileTrackerFeature } from "./FileTrackFeature"; export class Client { @@ -24,6 +25,7 @@ export class Client { readonly agent: AgentFeature; readonly chat: ChatFeature; readonly telemetry: TelemetryFeature; + readonly workspace: WorkSpaceFeature; readonly fileTrack: FileTrackerFeature; constructor( private readonly context: ExtensionContext, @@ -31,10 +33,12 @@ export class Client { ) { this.agent = new AgentFeature(this.languageClient); this.chat = new ChatFeature(this.languageClient); + this.workspace = new WorkSpaceFeature(this.languageClient); this.telemetry = new TelemetryFeature(this.languageClient); this.fileTrack = new FileTrackerFeature(this, this.context); this.languageClient.registerFeature(this.agent); this.languageClient.registerFeature(this.chat); + this.languageClient.registerFeature(this.workspace); this.languageClient.registerFeature(this.telemetry); this.languageClient.registerFeature(this.fileTrack); this.languageClient.registerFeature(new DataStoreFeature(this.context, this.languageClient)); diff --git a/clients/vscode/src/lsp/WorkspaceFeature.ts b/clients/vscode/src/lsp/WorkspaceFeature.ts new file mode 100644 index 000000000000..f3598921b4d6 --- /dev/null +++ b/clients/vscode/src/lsp/WorkspaceFeature.ts @@ -0,0 +1,109 @@ +import EventEmitter from "events"; +import { ApplyWorkspaceEditParams, ApplyWorkspaceEditRequest } from "tabby-agent"; +import { BaseLanguageClient, FeatureState, StaticFeature, TextEdit } from "vscode-languageclient"; +import { Disposable, Position, Range, TextDocument, TextEditorEdit, window, workspace } from "vscode"; +import { diffLines } from "diff"; + +export class WorkSpaceFeature extends EventEmitter implements StaticFeature { + private disposables: Disposable[] = []; + + constructor(private readonly client: BaseLanguageClient) { + super(); + } + getState(): FeatureState { + return { kind: "static" }; + } + + fillInitializeParams() { + // nothing + } + + fillClientCapabilities(): void { + // nothing + } + + preInitialize(): void { + // nothing + } + + initialize(): void { + this.disposables.push( + this.client.onRequest(ApplyWorkspaceEditRequest.type, (params: ApplyWorkspaceEditParams) => { + return this.handleApplyWorkspaceEdit(params); + }), + ); + } + + clear(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables = []; + } + + private async handleApplyWorkspaceEdit(params: ApplyWorkspaceEditParams): Promise { + const { edit, options } = params; + const activeEditor = window.activeTextEditor; + if (!activeEditor) { + return false; + } + + try { + const success = await activeEditor.edit( + (editBuilder: TextEditorEdit) => { + Object.entries(edit.changes || {}).forEach(([uri, textEdits]) => { + const document = workspace.textDocuments.find((doc) => doc.uri.toString() === uri); + if (document && document === activeEditor.document) { + const textEdit = textEdits[0]; + if (textEdits.length === 1 && textEdit) { + applyTextEditMinimalLineChange(editBuilder, textEdit, document); + } else { + textEdits.forEach((textEdit) => { + const range = new Range( + new Position(textEdit.range.start.line, textEdit.range.start.character), + new Position(textEdit.range.end.line, textEdit.range.end.character), + ); + editBuilder.replace(range, textEdit.newText); + }); + } + } + }); + }, + { + undoStopBefore: options?.undoStopBefore ?? false, + undoStopAfter: options?.undoStopAfter ?? false, + }, + ); + + return success; + } catch (error) { + return false; + } + } +} + +function applyTextEditMinimalLineChange(editBuilder: TextEditorEdit, textEdit: TextEdit, document: TextDocument) { + const documentRange = new Range( + new Position(textEdit.range.start.line, textEdit.range.start.character), + new Position(textEdit.range.end.line, textEdit.range.end.character), + ); + + const text = document.getText(documentRange); + const newText = textEdit.newText; + const diffs = diffLines(text, newText); + + let line = documentRange.start.line; + for (const diff of diffs) { + if (!diff.count) { + continue; + } + + if (diff.added) { + editBuilder.insert(new Position(line, 0), diff.value); + } else if (diff.removed) { + const range = new Range(new Position(line + 0, 0), new Position(line + diff.count, 0)); + editBuilder.delete(range); + line += diff.count; + } else { + line += diff.count; + } + } +} diff --git a/ee/tabby-ui/app/files/components/chat-side-bar.tsx b/ee/tabby-ui/app/files/components/chat-side-bar.tsx index b1322f39b6df..a17d7c118e4a 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -77,7 +77,7 @@ export const ChatSideBar: React.FC = ({ }) }, async onSubmitMessage(_msg, _relevantContext) {}, - onApplyInEditor(_content) {}, + onApplyInEditor(_content, _args) {}, onLoaded() {}, onCopy(_content) {}, onKeyboardEvent() {} diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index 8795238f3924..c67e6785bed3 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -40,7 +40,10 @@ type ChatContextValue = { onClearMessages: () => void container?: HTMLDivElement onCopyContent?: (value: string) => void - onApplyInEditor?: (value: string) => void + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void relevantContext: Context[] activeSelection: Context | null removeRelevantContext: (index: number) => void @@ -75,7 +78,10 @@ interface ChatProps extends React.ComponentProps<'div'> { promptFormClassname?: string onCopyContent?: (value: string) => void onSubmitMessage?: (msg: string, relevantContext?: Context[]) => Promise - onApplyInEditor?: (value: string) => void + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void chatInputRef: RefObject } diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index 58a5ac14b8ae..cb41ee5aa211 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -64,7 +64,10 @@ export interface MessageMarkdownProps { attachmentDocs?: Maybe> attachmentCode?: Maybe> onCopyContent?: ((value: string) => void) | undefined - onApplyInEditor?: ((value: string) => void) | undefined + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void onCodeCitationClick?: (code: MessageAttachmentCode) => void onCodeCitationMouseEnter?: (index: number) => void onCodeCitationMouseLeave?: (index: number) => void @@ -77,7 +80,10 @@ export interface MessageMarkdownProps { type MessageMarkdownContextValue = { onCopyContent?: ((value: string) => void) | undefined - onApplyInEditor?: ((value: string) => void) | undefined + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void onCodeCitationClick?: (code: MessageAttachmentCode) => void onCodeCitationMouseEnter?: (index: number) => void onCodeCitationMouseLeave?: (index: number) => void diff --git a/ee/tabby-ui/components/ui/codeblock.tsx b/ee/tabby-ui/components/ui/codeblock.tsx index dfc7aefcd719..2831fc6f146a 100644 --- a/ee/tabby-ui/components/ui/codeblock.tsx +++ b/ee/tabby-ui/components/ui/codeblock.tsx @@ -17,6 +17,7 @@ import { IconApplyInEditor, IconCheck, IconCopy, + IconSmartApplyInEditor, IconWrapText } from '@/components/ui/icons' import { @@ -29,8 +30,11 @@ export interface CodeBlockProps { language: string value: string onCopyContent?: (value: string) => void - onApplyInEditor?: (value: string) => void - canWrapLongLines?: boolean + onApplyInEditor?: ( + value: string, + opts?: { languageId: string; smart: boolean } + ) => void + canWrapLongLines: boolean | undefined } interface languageMap { @@ -118,7 +122,30 @@ const CodeBlock: FC = memo( variant="ghost" size="icon" className="text-xs hover:bg-[#3C382F] hover:text-[#F4F4F5] focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0" - onClick={() => onApplyInEditor(value)} + onClick={() => + onApplyInEditor(value, { + languageId: language, + smart: true + }) + } + > + + Smart Apply in Editor + + + +

Smart Apply in Editor

+
+ + )} + {onApplyInEditor && ( + + +