diff --git a/clients/tabby-agent/src/chat/utils.ts b/clients/tabby-agent/src/chat/utils.ts index 4fedbbd4006c..f09aa833b715 100644 --- a/clients/tabby-agent/src/chat/utils.ts +++ b/clients/tabby-agent/src/chat/utils.ts @@ -231,7 +231,6 @@ export async function showDocument(params: ShowDocumentParams, lspConnection: Co // [+] inserted // [-] deleted // [>] footer -// [x] stopped // footer line // >>>>>>> End of changes export function generateChangesPreview(edit: Edit): string[] { @@ -301,7 +300,7 @@ export function generateChangesPreview(edit: Edit): string[] { } if (inProgressChunk && lastDiff) { if (edit.state === "stopped") { - pushDiffValue(lastDiff.value, "x"); + pushDiffValue(lastDiff.value, "+"); } else { pushDiffValue(lastDiff.value, "|"); } @@ -312,7 +311,7 @@ export function generateChangesPreview(edit: Edit): string[] { break; } if (edit.state === "stopped") { - pushDiffValue(diff.value, "x"); + pushDiffValue(diff.value, "="); } else { pushDiffValue(diff.value, "."); } diff --git a/clients/tabby-agent/src/codeLens.ts b/clients/tabby-agent/src/codeLens.ts index 848dadd5faf4..8ac5fe2e80db 100644 --- a/clients/tabby-agent/src/codeLens.ts +++ b/clients/tabby-agent/src/codeLens.ts @@ -91,7 +91,7 @@ export class CodeLensProvider implements Feature { if (match && editId) { lineInPreviewBlock = -1; - if (previewBlockMarkers.includes(".")) { + if (previewBlockMarkers.includes(".") || previewBlockMarkers.includes("|")) { lineCodeLenses.push({ range: codeLensRange, command: { @@ -115,7 +115,7 @@ export class CodeLensProvider implements Feature { line: changesPreviewLineType.header, }, }); - } else if (!previewBlockMarkers.includes("x")) { + } else { lineCodeLenses.push({ range: codeLensRange, command: { diff --git a/clients/vscode/package.json b/clients/vscode/package.json index 4c754ea51175..c849743b1d4f 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -397,12 +397,12 @@ "command": "tabby.chat.edit.accept", "key": "ctrl+enter", "mac": "cmd+enter", - "when": "tabby.chatEditResolving && editorTextFocus && !editorReadonly" + "when": "tabby.chatEditResolving && !tabby.chatEditInProgress && editorTextFocus && !editorReadonly" }, { "command": "tabby.chat.edit.discard", "key": "escape", - "when": "tabby.chatEditResolving && editorTextFocus && !editorReadonly" + "when": "tabby.chatEditResolving && !tabby.chatEditInProgress && editorTextFocus && !editorReadonly" }, { "command": "tabby.chat.addRelevantContext", diff --git a/clients/vscode/src/code-action/QuickFix.ts b/clients/vscode/src/code-action/QuickFix.ts index 2d661a9262a6..8358908c2beb 100644 --- a/clients/vscode/src/code-action/QuickFix.ts +++ b/clients/vscode/src/code-action/QuickFix.ts @@ -63,7 +63,7 @@ export class QuickFixCodeActionProvider implements CodeActionProviderInterface { quickFixEditing.command = { command: "tabby.chat.edit.start", title: "Fix using Tabby", - arguments: [quickFixCmd, mergedRange], + arguments: [undefined, mergedRange, quickFixCmd], }; const explainErrorCmd = `\nHere is some error information that occurred in the selection: diff --git a/clients/vscode/src/commands/index.ts b/clients/vscode/src/commands/index.ts index f33ed29eaa03..3eb5bbb097ea 100644 --- a/clients/vscode/src/commands/index.ts +++ b/clients/vscode/src/commands/index.ts @@ -266,48 +266,51 @@ export class Commands { "chat.createPanel": async () => { await createChatPanel(this.context, this.client, this.gitProvider); }, - "chat.edit.start": async (userCommand?: string, range?: Range) => { - const editor = window.activeTextEditor; - if (!editor) { + "chat.edit.start": async ( + fileUri?: string | undefined, + range?: Range | undefined, + userCommand?: string | undefined, + ) => { + if (this.contextVariables.chatEditInProgress) { + window.setStatusBarMessage("Edit is already in progress.", 3000); return; } - const editRange = range || editor.selection; - - const editLocation = { - uri: editor.document.uri.toString(), - range: { - start: { line: editRange.start.line, character: 0 }, - end: { - line: editRange.end.character === 0 ? editRange.end.line : editRange.end.line + 1, - character: 0, - }, - }, - }; - - if (userCommand) { + let editor: TextEditor | undefined; + if (fileUri) { try { - // when invoke from editor context menu, the first param `userCommand` is the current file path, we reset userCommand to undefined. - // uri parse will throw error when no scheme can be parsed. - Uri.parse(userCommand, true); - userCommand = undefined; + const uri = Uri.parse(fileUri, true); + editor = window.visibleTextEditors.find((editor) => editor.document.uri.toString() === uri.toString()); } catch { - // + // ignore } } + if (!editor) { + editor = window.activeTextEditor; + } + if (!editor) { + return; + } + + const editRange = range ?? editor.selection; const inlineEditController = new InlineEditController( this.client, this.config, this.contextVariables, editor, - editLocation, - userCommand, + editRange, ); - inlineEditController.start(); + const cancellationTokenSource = new CancellationTokenSource(); + this.chatEditCancellationTokenSource = cancellationTokenSource; + await inlineEditController.start(userCommand, cancellationTokenSource.token); + cancellationTokenSource.dispose(); + this.chatEditCancellationTokenSource = null; }, "chat.edit.stop": async () => { this.chatEditCancellationTokenSource?.cancel(); + this.chatEditCancellationTokenSource?.dispose(); + this.chatEditCancellationTokenSource = null; }, "chat.edit.accept": async () => { const editor = window.activeTextEditor; diff --git a/clients/vscode/src/inline-edit/index.ts b/clients/vscode/src/inline-edit/index.ts index 20ad4644c803..b0d6b80ecca4 100644 --- a/clients/vscode/src/inline-edit/index.ts +++ b/clients/vscode/src/inline-edit/index.ts @@ -1,5 +1,4 @@ -import { ChatEditCommand } from "tabby-agent"; -import { Config } from "../Config"; +import type { Location } from "vscode-languageclient"; import { CancellationTokenSource, QuickPickItem, @@ -9,82 +8,173 @@ import { TextEditor, Selection, Position, - QuickPick, - QuickPickItemButtonEvent, + CancellationToken, + Range, } from "vscode"; +import { ChatEditCommand } from "tabby-agent"; import { Client } from "../lsp/Client"; +import { Config } from "../Config"; import { ContextVariables } from "../ContextVariables"; import { getLogger } from "../logger"; export class InlineEditController { private readonly logger = getLogger("InlineEditController"); - private chatEditCancellationTokenSource: CancellationTokenSource | null = null; - private quickPick: QuickPick; - - private recentlyCommand: string[] = []; - private suggestedCommand: ChatEditCommand[] = []; + private readonly editLocation: Location; constructor( private client: Client, private config: Config, private contextVariables: ContextVariables, private editor: TextEditor, - private editLocation: EditLocation, - private userCommand?: string, + private range: Range, ) { - this.recentlyCommand = this.config.chatEditRecentlyCommand.slice(0, this.config.maxChatEditHistory); - - const fetchingSuggestedCommandCancellationTokenSource = new CancellationTokenSource(); - this.client.chat.provideEditCommands( - { location: editLocation }, - { commands: this.suggestedCommand, callback: () => this.updateQuickPickList() }, - fetchingSuggestedCommandCancellationTokenSource.token, - ); - - const quickPick = window.createQuickPick(); - quickPick.placeholder = "Enter the command for editing"; - quickPick.matchOnDescription = true; - quickPick.onDidChangeValue(() => this.updateQuickPickList()); - quickPick.onDidHide(() => { - fetchingSuggestedCommandCancellationTokenSource.cancel(); - }); - quickPick.onDidAccept(this.onDidAccept, this); - quickPick.onDidTriggerItemButton(this.onDidTriggerItemButton, this); - - this.quickPick = quickPick; - } - - async start() { - this.logger.log(`Start inline edit with user command: ${this.userCommand}`); - this.userCommand ? await this.provideEditWithCommand(this.userCommand) : this.quickPick.show(); + this.editLocation = { + uri: this.editor.document.uri.toString(), + range: { + start: { line: this.range.start.line, character: 0 }, + end: { + line: this.range.end.character === 0 ? this.range.end.line : this.range.end.line + 1, + character: 0, + }, + }, + }; } - private async onDidAccept() { - const command = this.quickPick.selectedItems[0]?.value; - this.quickPick.hide(); - if (!command) { - return; + async start(userCommand: string | undefined, cancellationToken: CancellationToken) { + const command = userCommand ?? (await this.showQuickPick()); + if (command) { + this.logger.log(`Start inline edit with user command: ${command}`); + await this.provideEditWithCommand(command, cancellationToken); } - if (command && command.length > 200) { - window.showErrorMessage("Command is too long."); - return; - } - await this.provideEditWithCommand(command); } - private async provideEditWithCommand(command: string) { - const startPosition = new Position(this.editLocation.range.start.line, this.editLocation.range.start.character); + private async showQuickPick(): Promise { + return new Promise((resolve) => { + const quickPick = window.createQuickPick(); + quickPick.placeholder = "Enter the command for editing"; + quickPick.matchOnDescription = true; + + const recentlyCommand = this.config.chatEditRecentlyCommand.slice(0, this.config.maxChatEditHistory); + const suggestedCommand: ChatEditCommand[] = []; + + const updateQuickPickList = () => { + const input = quickPick.value; + const list: CommandQuickPickItem[] = []; + list.push( + ...suggestedCommand.map((item) => ({ + label: item.label, + value: item.command, + iconPath: item.source === "preset" ? new ThemeIcon("run") : new ThemeIcon("spark"), + description: item.source === "preset" ? item.command : "Suggested", + })), + ); + if (list.length > 0) { + list.push({ + label: "", + value: "", + kind: QuickPickItemKind.Separator, + alwaysShow: true, + }); + } + const recentlyCommandToAdd = recentlyCommand.filter((item) => !list.find((i) => i.value === item)); + list.push( + ...recentlyCommandToAdd.map((item) => ({ + label: item, + value: item, + iconPath: new ThemeIcon("history"), + description: "History", + buttons: [ + { + iconPath: new ThemeIcon("edit"), + }, + { + iconPath: new ThemeIcon("settings-remove"), + }, + ], + })), + ); + if (input.length > 0 && !list.find((i) => i.value === input)) { + list.unshift({ + label: input, + value: input, + iconPath: new ThemeIcon("run"), + description: "", + alwaysShow: true, + }); + } + quickPick.items = list; + }; + + quickPick.onDidChangeValue(() => updateQuickPickList()); + + const fetchingSuggestedCommandCancellationTokenSource = new CancellationTokenSource(); + this.client.chat.provideEditCommands( + { location: this.editLocation }, + { commands: suggestedCommand, callback: () => updateQuickPickList() }, + fetchingSuggestedCommandCancellationTokenSource.token, + ); - if (!this.userCommand) { - const updatedRecentlyCommand = [command] - .concat(this.recentlyCommand.filter((item) => item !== command)) - .slice(0, this.config.maxChatEditHistory); - await this.config.updateChatEditRecentlyCommand(updatedRecentlyCommand); - } + quickPick.onDidTriggerItemButton(async (event) => { + const item = event.item; + const button = event.button; + if (button.iconPath instanceof ThemeIcon && button.iconPath.id === "settings-remove") { + const index = recentlyCommand.indexOf(item.value); + if (index !== -1) { + recentlyCommand.splice(index, 1); + await this.config.updateChatEditRecentlyCommand(recentlyCommand); + updateQuickPickList(); + } + } + + if (button.iconPath instanceof ThemeIcon && button.iconPath.id === "edit") { + quickPick.value = item.value; + } + }); + + quickPick.onDidAccept(async () => { + const command = quickPick.selectedItems[0]?.value; + if (!command) { + resolve(undefined); + return; + } + if (command && command.length > 200) { + window.showErrorMessage("Command is too long."); + resolve(undefined); + return; + } + + const recentlyCommand = this.config.chatEditRecentlyCommand; + const updatedRecentlyCommand = [command] + .concat(recentlyCommand.filter((item) => item !== command)) + .slice(0, this.config.maxChatEditHistory); + await this.config.updateChatEditRecentlyCommand(updatedRecentlyCommand); + + resolve(command); + quickPick.hide(); + }); + quickPick.onDidHide(() => { + fetchingSuggestedCommandCancellationTokenSource.cancel(); + resolve(undefined); + }); + + quickPick.show(); + }); + } + + private async provideEditWithCommand(command: string, cancellationToken: CancellationToken) { + // Lock the cursor (editor selection) at start position, it will be unlocked after the edit is done + const startPosition = new Position(this.range.start.line, 0); + const resetEditorSelection = () => { + this.editor.selection = new Selection(startPosition, startPosition); + }; + const selectionListenerDisposable = window.onDidChangeTextEditorSelection((event) => { + if (event.textEditor === this.editor) { + resetEditorSelection(); + } + }); + resetEditorSelection(); - this.editor.selection = new Selection(startPosition, startPosition); this.contextVariables.chatEditInProgress = true; - this.chatEditCancellationTokenSource = new CancellationTokenSource(); this.logger.log(`Provide edit with command: ${command}`); try { await this.client.chat.provideEdit( @@ -93,93 +183,22 @@ export class InlineEditController { command, format: "previewChanges", }, - this.chatEditCancellationTokenSource.token, + cancellationToken, ); } catch (error) { if (typeof error === "object" && error && "message" in error && typeof error["message"] === "string") { - window.showErrorMessage(error["message"]); + if (cancellationToken.isCancellationRequested || error["message"].includes("This operation was aborted")) { + // user canceled + } else { + window.showErrorMessage(error["message"]); + } } } - this.chatEditCancellationTokenSource.dispose(); - this.chatEditCancellationTokenSource = null; + selectionListenerDisposable.dispose(); this.contextVariables.chatEditInProgress = false; - this.editor.selection = new Selection(startPosition, startPosition); - } - - private async onDidTriggerItemButton(event: QuickPickItemButtonEvent) { - const item = event.item; - const button = event.button; - if (button.iconPath instanceof ThemeIcon && button.iconPath.id === "settings-remove") { - const index = this.recentlyCommand.indexOf(item.value); - if (index !== -1) { - this.recentlyCommand.splice(index, 1); - await this.config.updateChatEditRecentlyCommand(this.recentlyCommand); - this.updateQuickPickList(); - } - } - - if (button.iconPath instanceof ThemeIcon && button.iconPath.id === "edit") { - this.quickPick.value = item.value; - } - } - - private updateQuickPickList() { - const input = this.quickPick.value; - const list: (QuickPickItem & { value: string })[] = []; - list.push( - ...this.suggestedCommand.map((item) => ({ - label: item.label, - value: item.command, - iconPath: item.source === "preset" ? new ThemeIcon("run") : new ThemeIcon("spark"), - description: item.source === "preset" ? item.command : "Suggested", - })), - ); - if (list.length > 0) { - list.push({ - label: "", - value: "", - kind: QuickPickItemKind.Separator, - alwaysShow: true, - }); - } - const recentlyCommandToAdd = this.recentlyCommand.filter((item) => !list.find((i) => i.value === item)); - list.push( - ...recentlyCommandToAdd.map((item) => ({ - label: item, - value: item, - iconPath: new ThemeIcon("history"), - description: "History", - buttons: [ - { - iconPath: new ThemeIcon("edit"), - }, - { - iconPath: new ThemeIcon("settings-remove"), - }, - ], - })), - ); - if (input.length > 0 && !list.find((i) => i.value === input)) { - list.unshift({ - label: input, - value: input, - iconPath: new ThemeIcon("run"), - description: "", - alwaysShow: true, - }); - } - this.quickPick.items = list; } } -interface EditCommand extends QuickPickItem { +interface CommandQuickPickItem extends QuickPickItem { value: string; } - -interface EditLocation { - uri: string; - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; -}