From 72ee67fe29ef635e4ec06f51489bb9a5819614b3 Mon Sep 17 00:00:00 2001 From: Beatrix <68532117+abeatrix@users.noreply.github.com> Date: Fri, 26 May 2023 17:31:11 -0700 Subject: [PATCH] Cody: Add Task View Base for Non-Stop Cody (#52282) Adds skeleton tree view for Non-Stop Cody --- client/cody-shared/BUILD.bazel | 1 + client/cody-shared/src/chat/recipes/fixup.ts | 2 +- .../src/chat/recipes/inline-chat.ts | 2 +- .../cody-shared/src/chat/recipes/non-stop.ts | 112 ++++++++++++ client/cody-shared/src/chat/recipes/recipe.ts | 1 + client/cody-shared/src/configuration.ts | 3 +- client/cody-shared/src/editor/index.ts | 14 +- client/cody/BUILD.bazel | 5 + client/cody/package.json | 110 ++++++++++- client/cody/src/chat/ChatViewProvider.ts | 11 +- client/cody/src/chat/recipes.ts | 2 + client/cody/src/configuration.test.ts | 4 + client/cody/src/configuration.ts | 1 + client/cody/src/editor/vscode-editor.ts | 14 +- client/cody/src/main.ts | 16 +- client/cody/src/non-stop/FixupTask.ts | 80 ++++++++ client/cody/src/non-stop/TaskController.ts | 171 +++++++++++++++++ client/cody/src/non-stop/TaskViewProvider.ts | 173 ++++++++++++++++++ client/cody/src/non-stop/utils.test.ts | 20 ++ client/cody/src/non-stop/utils.ts | 54 ++++++ client/cody/src/test-support.ts | 5 + client/cody/test/e2e/BUILD.bazel | 1 + client/cody/test/e2e/auth.test.ts | 5 + client/cody/test/e2e/common.ts | 7 +- client/cody/test/e2e/history.test.ts | 5 + client/cody/test/e2e/inline-assist.test.ts | 2 +- client/cody/test/e2e/task-controller.test.ts | 67 +++++++ client/cody/test/fixtures/mock-server.ts | 18 +- client/cody/test/integration/BUILD.bazel | 1 + client/cody/test/integration/helpers.ts | 21 ++- .../test/integration/task-controller.test.ts | 89 +++++++++ 31 files changed, 1001 insertions(+), 16 deletions(-) create mode 100644 client/cody-shared/src/chat/recipes/non-stop.ts create mode 100644 client/cody/src/non-stop/FixupTask.ts create mode 100644 client/cody/src/non-stop/TaskController.ts create mode 100644 client/cody/src/non-stop/TaskViewProvider.ts create mode 100644 client/cody/src/non-stop/utils.test.ts create mode 100644 client/cody/src/non-stop/utils.ts create mode 100644 client/cody/test/e2e/task-controller.test.ts create mode 100644 client/cody/test/integration/task-controller.test.ts diff --git a/client/cody-shared/BUILD.bazel b/client/cody-shared/BUILD.bazel index 773467afa2a..8c789f43576 100644 --- a/client/cody-shared/BUILD.bazel +++ b/client/cody-shared/BUILD.bazel @@ -45,6 +45,7 @@ ts_project( "src/chat/recipes/inline-chat.ts", "src/chat/recipes/langs.ts", "src/chat/recipes/next-questions.ts", + "src/chat/recipes/non-stop.ts", "src/chat/recipes/optimize-code.ts", "src/chat/recipes/recipe.ts", "src/chat/recipes/translate.ts", diff --git a/client/cody-shared/src/chat/recipes/fixup.ts b/client/cody-shared/src/chat/recipes/fixup.ts index 6b373558c76..b4a248375ce 100644 --- a/client/cody-shared/src/chat/recipes/fixup.ts +++ b/client/cody-shared/src/chat/recipes/fixup.ts @@ -13,7 +13,7 @@ export class Fixup implements Recipe { public async getInteraction(humanChatInput: string, context: RecipeContext): Promise { // TODO: Prompt the user for additional direction. - const selection = context.editor.getActiveTextEditorSelection() || context.editor.controller?.selection + const selection = context.editor.getActiveTextEditorSelection() || context.editor.controllers?.inline.selection if (!selection) { await context.editor.showWarningMessage('Select some code to fixup.') return null diff --git a/client/cody-shared/src/chat/recipes/inline-chat.ts b/client/cody-shared/src/chat/recipes/inline-chat.ts index 09378c7a0b1..31876954fe7 100644 --- a/client/cody-shared/src/chat/recipes/inline-chat.ts +++ b/client/cody-shared/src/chat/recipes/inline-chat.ts @@ -13,7 +13,7 @@ export class InlineChat implements Recipe { public id: RecipeID = 'inline-chat' public async getInteraction(humanChatInput: string, context: RecipeContext): Promise { - const selection = context.editor.controller?.selection + const selection = context.editor.controllers?.inline.selection if (!humanChatInput || !selection) { await context.editor.showWarningMessage('Failed to start Inline Chat: empty input or selection.') return null diff --git a/client/cody-shared/src/chat/recipes/non-stop.ts b/client/cody-shared/src/chat/recipes/non-stop.ts new file mode 100644 index 00000000000..c2e08bf63d7 --- /dev/null +++ b/client/cody-shared/src/chat/recipes/non-stop.ts @@ -0,0 +1,112 @@ +import { CodebaseContext } from '../../codebase-context' +import { ContextMessage } from '../../codebase-context/messages' +import { MAX_CURRENT_FILE_TOKENS, MAX_HUMAN_INPUT_TOKENS } from '../../prompt/constants' +import { truncateText, truncateTextStart } from '../../prompt/truncation' +import { BufferedBotResponseSubscriber } from '../bot-response-multiplexer' +import { Interaction } from '../transcript/interaction' + +import { Recipe, RecipeContext, RecipeID } from './recipe' + +// TODO: Disconnect recipe from chat +export class NonStop implements Recipe { + public id: RecipeID = 'non-stop' + + public async getInteraction(humanChatInput: string, context: RecipeContext): Promise { + const controllers = context.editor.controllers + const selection = context.editor.getActiveTextEditorSelection() + + if (!controllers || !selection) { + await context.editor.showWarningMessage('Cody Fixups: Failed to start.') + return null + } + + // TODO: Remove dependency on human input and use input box only + const humanInput = + humanChatInput || + (await context.editor.showInputBox('Ask Cody to edit your code, or use /chat to ask a question.')) || + '' + + const taskID = controllers.task.add(humanInput, selection) + if ((!humanInput && !selection.selectedText.trim()) || !taskID) { + await context.editor.showWarningMessage( + 'Cody Fixups: Failed to start due to missing instruction with empty selection.' + ) + return null + } + + const quarterFileContext = Math.floor(MAX_CURRENT_FILE_TOKENS / 4) + if (truncateText(selection.selectedText, quarterFileContext * 2) !== selection.selectedText) { + const msg = "The amount of text selected exceeds Cody's current capacity." + await context.editor.showWarningMessage(msg) + return null + } + + // Reconstruct Cody's prompt using user's context + // Replace placeholders in reverse order to avoid collisions if a placeholder occurs in the input + const promptText = NonStop.prompt + .replace('{humanInput}', truncateText(humanInput, MAX_HUMAN_INPUT_TOKENS)) + .replace('{responseMultiplexerPrompt}', context.responseMultiplexer.prompt()) + .replace('{truncateFollowingText}', truncateText(selection.followingText, quarterFileContext)) + .replace('{selectedText}', selection.selectedText) + .replace('{truncateTextStart}', truncateTextStart(selection.precedingText, quarterFileContext)) + .replace('{fileName}', selection.fileName) + + context.responseMultiplexer.sub( + 'selection', + new BufferedBotResponseSubscriber(async content => { + // TODO Handles LLM output + // TODO Replace the selected text with the suggested replacement + // Mark the task as done + controllers.task.stop(taskID) + if (!content) { + await context.editor.showWarningMessage('Cody did not suggest any replacement.') + } + }) + ) + + return Promise.resolve( + new Interaction( + { + speaker: 'human', + text: promptText, + displayText: 'Cody Fixups: ' + humanInput, + }, + { + speaker: 'assistant', + prefix: 'Check your document for updates from Cody.', + }, + this.getContextMessages(selection.selectedText, context.codebaseContext) + ) + ) + } + + // Get context from editor + private async getContextMessages(text: string, codebaseContext: CodebaseContext): Promise { + const contextMessages: ContextMessage[] = await codebaseContext.getContextMessages(text, { + numCodeResults: 12, + numTextResults: 3, + }) + return contextMessages + } + + // Prompt Templates + public static readonly prompt = ` + This is part of the file {fileName}. The part of the file I have selected is enclosed with the tags. You are helping me to work on that part as my coding assistant. + Follow the instructions in the selected part along with the additional instruction provide below to produce a rewritten replacement for only the selected part. + Put the rewritten replacement inside tags. I only want to see the code within . + Do not move code from outside the selection into the selection in your reply. + Do not remove code inside the tags that might be being used by the code outside the tags. + Do not enclose replacement code with tags other than the tags. + Do not enclose your answer with any markdown. + Only return provide me the replacement and nothing else. + If it doesn't make sense, you do not need to provide . + + \`\`\` + {truncateTextStart}{selectedText}{truncateFollowingText} + \`\`\` + + Additional Instruction: + - {humanInput} + - {responseMultiplexerPrompt} +` +} diff --git a/client/cody-shared/src/chat/recipes/recipe.ts b/client/cody-shared/src/chat/recipes/recipe.ts index 49ba1357d81..98c0613ff9a 100644 --- a/client/cody-shared/src/chat/recipes/recipe.ts +++ b/client/cody-shared/src/chat/recipes/recipe.ts @@ -28,6 +28,7 @@ export type RecipeID = | 'release-notes' | 'inline-chat' | 'next-questions' + | 'non-stop' | 'optimize-code' export interface Recipe { diff --git a/client/cody-shared/src/configuration.ts b/client/cody-shared/src/configuration.ts index 4ae83059f53..1d3ba1e7805 100644 --- a/client/cody-shared/src/configuration.ts +++ b/client/cody-shared/src/configuration.ts @@ -7,11 +7,12 @@ export interface Configuration { debugFilter: RegExp | null debugVerbose: boolean useContext: ConfigurationUseContext + customHeaders: Record experimentalSuggest: boolean experimentalChatPredictions: boolean experimentalInline: boolean experimentalGuardrails: boolean - customHeaders: Record + experimentalNonStop: boolean } export interface ConfigurationWithAccessToken extends Configuration { diff --git a/client/cody-shared/src/editor/index.ts b/client/cody-shared/src/editor/index.ts index ec8c7987b90..892730d287b 100644 --- a/client/cody-shared/src/editor/index.ts +++ b/client/cody-shared/src/editor/index.ts @@ -21,12 +21,22 @@ export interface ActiveTextEditorVisibleContent { revision?: string } -export interface InlineController { +interface VsCodeInlineController { selection: ActiveTextEditorSelection | null } +interface VsCodeTaskContoller { + add(input: string, selection: ActiveTextEditorSelection): string | null + stop(taskID: string): void +} + +export interface ActiveTextEditorViewControllers { + inline: VsCodeInlineController + task: VsCodeTaskContoller +} + export interface Editor { - controller?: InlineController + controllers?: ActiveTextEditorViewControllers getWorkspaceRootPath(): string | null getActiveTextEditor(): ActiveTextEditor | null getActiveTextEditorSelection(): ActiveTextEditorSelection | null diff --git a/client/cody/BUILD.bazel b/client/cody/BUILD.bazel index b6574c135d4..7019bdc6082 100644 --- a/client/cody/BUILD.bazel +++ b/client/cody/BUILD.bazel @@ -53,6 +53,10 @@ ts_project( "src/local-app-detector.ts", "src/log.ts", "src/main.ts", + "src/non-stop/FixupTask.ts", + "src/non-stop/TaskController.ts", + "src/non-stop/TaskViewProvider.ts", + "src/non-stop/utils.ts", "src/rg.ts", "src/services/CodeLensProvider.ts", "src/services/DecorationProvider.ts", @@ -120,6 +124,7 @@ ts_project( "src/completions/provider.test.ts", "src/configuration.test.ts", "src/keyword-context/local-keyword-context-fetcher.test.ts", + "src/non-stop/utils.test.ts", "src/services/InlineAssist.test.ts", "test/fixtures/mock-server.ts", ], diff --git a/client/cody/package.json b/client/cody/package.json index 1f9280e80e6..c41d43d55d2 100644 --- a/client/cody/package.json +++ b/client/cody/package.json @@ -90,9 +90,23 @@ "id": "cody.chat", "name": "Chat", "visibility": "visible" + }, + { + "id": "cody.fixup.tree.view", + "name": "Fixups", + "when": "cody.nonstop.fixups.enabled && cody.activated", + "icon": "cody.svg", + "contextualTitle": "Fixups" } ] }, + "viewsWelcome": [ + { + "view": "cody.fixup.tree.view", + "contents": "No pending Cody fixups", + "when": "cody.nonstop.fixups.enabled" + } + ], "commands": [ { "command": "cody.recipe.optimize-code", @@ -192,7 +206,43 @@ "command": "cody.guardrails.debug", "title": "Cody: Guardrails Debug Attribution", "enablement": "config.cody.debug && config.cody.experimental.guardrails && editorHasSelection" - } + }, + { + "command": "cody.recipe.non-stop", + "title": "Cody: Fixup (Experimental)", + "icon": "resources/cody.png", + "enablement": "cody.nonstop.fixups.enabled" + }, + { + "command": "cody.fixup.open", + "title": "Cody: Go to Fixup", + "enablement": "cody.nonstop.fixups.enabled", + "icon": "$(file-code)" + }, + { + "command": "cody.fixup.apply", + "title": "Cody: Apply fixup", + "enablement": "!cody.fixup.view.isEmpty", + "icon": "$(check)" + }, + { + "command": "cody.fixup.apply-all", + "title": "Cody: Apply all fixups", + "enablement": "!cody.fixup.view.isEmpty", + "icon": "$(check-all)" + }, + { + "command": "cody.fixup.apply-by-file", + "title": "Cody: Apply fixups to selected directory", + "enablement": "!cody.fixup.view.isEmpty", + "icon": "$(check-all)" + }, + { + "command": "cody.fixup.diff", + "title": "Cody: Show diff for fixup", + "enablement": "!cody.fixup.view.isEmpty", + "icon": "$(diff)" + } ], "keybindings": [ { @@ -205,6 +255,12 @@ "key": "ctrl+alt+/", "mac": "ctrl+alt+/", "when": "cody.activated && editorTextFocus && !editorReadonly" + }, + { + "command": "cody.recipe.non-stop", + "key": "ctrl+shift+v", + "mac": "shift+cmd+v", + "when": "cody.activated && editorTextFocus && !editorReadonly" } ], "submenus": [ @@ -272,6 +328,22 @@ "command": "cody.comment.load", "when": "false" }, + { + "command": "cody.fixup.apply", + "when": "false" + }, + { + "command": "cody.fixup.apply-all", + "when": "false" + }, + { + "command": "cody.fixup.apply-by-file", + "when": "false" + }, + { + "command": "cody.fixup.diff", + "when": "false" + }, { "command": "cody.manual-completions", "when": "config.cody.experimental.suggestions" @@ -340,6 +412,19 @@ "command": "cody.history", "when": "view == cody.chat && cody.activated", "group": "navigation@2" + }, + { + "command": "cody.fixup.apply-all", + "when": "cody.nonstop.fixups.enabled && view == cody.fixup.tree.view && cody.activated", + "group": "navigation" + } + ], + "editor/title": [ + { + "command": "cody.recipe.non-stop", + "when": "cody.nonstop.fixups.enabled && cody.activated", + "group": "navigation", + "visibility": "visible" } ], "comments/commentThread/context": [ @@ -365,6 +450,24 @@ "group": "inline@2", "when": "cody.activated && commentController =~ /^cody-inline/ && cody.reply.pending" } + ], + "view/item/context": [ + { + "command": "cody.fixup.apply-by-file", + "when": "cody.nonstop.fixups.enabled && view == cody.fixup.tree.view && cody.activated && viewItem == fsPath", + "enable": "cody.fixup.filesWithApplicableFixups", + "group": "inline" + }, + { + "command": "cody.fixup.apply", + "when": "cody.nonstop.fixups.enabled && view == cody.fixup.tree.view && cody.activated && viewItem == task", + "group": "inline@2" + }, + { + "command": "cody.fixup.diff", + "when": "cody.nonstop.fixups.enabled && view == cody.fixup.tree.view && cody.activated && viewItem == task", + "group": "inline@1" + } ] }, "configuration": { @@ -431,6 +534,11 @@ "type": "boolean", "default": false }, + "cody.experimental.nonStop": { + "order": 9, + "type": "boolean", + "default": false + }, "cody.debug.enable": { "order": 99, "type": "boolean", diff --git a/client/cody/src/chat/ChatViewProvider.ts b/client/cody/src/chat/ChatViewProvider.ts index b111bfa179c..4c08c5ac95c 100644 --- a/client/cody/src/chat/ChatViewProvider.ts +++ b/client/cody/src/chat/ChatViewProvider.ts @@ -24,6 +24,7 @@ import { VSCodeEditor } from '../editor/vscode-editor' import { logEvent } from '../event-logger' import { LocalAppDetector } from '../local-app-detector' import { debug } from '../log' +import { FixupTask } from '../non-stop/FixupTask' import { LocalStorage } from '../services/LocalStorageProvider' import { CODY_ACCESS_TOKEN_SECRET, SecretStorage } from '../services/SecretStorageProvider' import { TestSupport } from '../test-support' @@ -305,7 +306,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp // TODO(keegancsmith) guardrails may be slow, we need to make this async update the interaction. highlightedDisplayText = await this.guardrailsAnnotateAttributions(highlightedDisplayText) this.transcript.addAssistantResponse(text || '', highlightedDisplayText) - this.editor.controller.reply(highlightedDisplayText) + this.editor.controllers.inline.reply(highlightedDisplayText) } void this.onCompletionEnd() }, @@ -793,6 +794,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp return this.transcript.toChat() } + public fixupTasksForTesting(testing: TestSupport): FixupTask[] { + if (!testing) { + console.error('used ForTesting method without test support object') + return [] + } + return this.editor.controllers.task.getTasks() + } + public dispose(): void { for (const disposable of this.disposables) { disposable.dispose() diff --git a/client/cody/src/chat/recipes.ts b/client/cody/src/chat/recipes.ts index f03af3eb1b6..92d57394e3e 100644 --- a/client/cody/src/chat/recipes.ts +++ b/client/cody/src/chat/recipes.ts @@ -11,6 +11,7 @@ import { GitHistory } from '@sourcegraph/cody-shared/src/chat/recipes/git-log' import { ImproveVariableNames } from '@sourcegraph/cody-shared/src/chat/recipes/improve-variable-names' import { InlineChat } from '@sourcegraph/cody-shared/src/chat/recipes/inline-chat' import { NextQuestions } from '@sourcegraph/cody-shared/src/chat/recipes/next-questions' +import { NonStop } from '@sourcegraph/cody-shared/src/chat/recipes/non-stop' import { OptimizeCode } from '@sourcegraph/cody-shared/src/chat/recipes/optimize-code' import { Recipe, RecipeID } from '@sourcegraph/cody-shared/src/chat/recipes/recipe' import { TranslateToLanguage } from '@sourcegraph/cody-shared/src/chat/recipes/translate' @@ -47,6 +48,7 @@ function init(): void { new NextQuestions(), new ContextSearch(), new ReleaseNotes(), + new NonStop(), new OptimizeCode(), ] diff --git a/client/cody/src/configuration.test.ts b/client/cody/src/configuration.test.ts index 5f27d6950d2..dfac591be85 100644 --- a/client/cody/src/configuration.test.ts +++ b/client/cody/src/configuration.test.ts @@ -15,6 +15,7 @@ describe('getConfiguration', () => { experimentalChatPredictions: false, experimentalGuardrails: false, experimentalInline: false, + experimentalNonStop: false, customHeaders: {}, debugEnable: false, debugVerbose: false, @@ -45,6 +46,8 @@ describe('getConfiguration', () => { return true case 'cody.experimental.inline': return true + case 'cody.experimental.nonStop': + return true case 'cody.debug.enable': return true case 'cody.debug.verbose': @@ -68,6 +71,7 @@ describe('getConfiguration', () => { experimentalChatPredictions: true, experimentalGuardrails: true, experimentalInline: true, + experimentalNonStop: true, debugEnable: true, debugVerbose: true, debugFilter: /.*/, diff --git a/client/cody/src/configuration.ts b/client/cody/src/configuration.ts index 8d12110c262..5e2137c43e5 100644 --- a/client/cody/src/configuration.ts +++ b/client/cody/src/configuration.ts @@ -41,6 +41,7 @@ export function getConfiguration(config: Pick { const activeEditor = this.getActiveTextEditorInstance() - if (this.controller.isInProgress) { - await this.controller.replaceSelection(replacement) + if (this.controllers.inline.isInProgress) { + await this.controllers.inline.replaceSelection(replacement) return } if (!activeEditor || vscode.workspace.asRelativePath(activeEditor.document.uri.fsPath) !== fileName) { diff --git a/client/cody/src/main.ts b/client/cody/src/main.ts index f94c11c9554..2fc3c059b1a 100644 --- a/client/cody/src/main.ts +++ b/client/cody/src/main.ts @@ -12,6 +12,7 @@ import { getConfiguration, getFullConfig } from './configuration' import { VSCodeEditor } from './editor/vscode-editor' import { logEvent, updateEventLogger } from './event-logger' import { configureExternalServices } from './external-services' +import { TaskController } from './non-stop/TaskController' import { getRgPath } from './rg' import { GuardrailsProvider } from './services/GuardrailsProvider' import { InlineController } from './services/InlineController' @@ -79,7 +80,10 @@ const register = async ( const commentController = new InlineController(context.extensionPath) disposables.push(commentController.get()) - const editor = new VSCodeEditor(commentController) + const taskController = new TaskController() + const controllers = { inline: commentController, task: taskController } + + const editor = new VSCodeEditor(controllers) const workspaceConfig = vscode.workspace.getConfiguration() const config = getConfiguration(workspaceConfig) @@ -252,6 +256,16 @@ const register = async ( }) ) } + // Register task view and non-stop cody command when feature flag is on + if (initialConfig.experimentalNonStop || process.env.CODY_TESTING === 'true') { + disposables.push(vscode.window.registerTreeDataProvider('cody.fixup.tree.view', taskController.getTaskView())) + disposables.push( + vscode.commands.registerCommand('cody.recipe.non-stop', async () => { + await chatProvider.executeRecipe('non-stop', '', false) + }) + ) + await vscode.commands.executeCommand('setContext', 'cody.nonstop.fixups.enabled', true) + } return { disposable: vscode.Disposable.from(...disposables), diff --git a/client/cody/src/non-stop/FixupTask.ts b/client/cody/src/non-stop/FixupTask.ts new file mode 100644 index 00000000000..834391c5c9e --- /dev/null +++ b/client/cody/src/non-stop/FixupTask.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode' + +import { ActiveTextEditorSelection } from '@sourcegraph/cody-shared/src/editor' + +import { debug } from '../log' + +import { CodyTaskState } from './utils' + +// TODO(bee): Create CodeLens for each task +// TODO(bee): Create decorator for each task +// TODO(dpc): Add listener for document change to track range +export class FixupTask { + public id: string + private outputChannel = debug + public selectionRange: vscode.Range + public state = CodyTaskState.idle + public readonly documentUri: vscode.Uri + + constructor( + public readonly instruction: string, + public selection: ActiveTextEditorSelection, + public readonly editor: vscode.TextEditor + ) { + this.id = Date.now().toString(36).replace(/\d+/g, '') + this.selectionRange = editor.selection + this.documentUri = editor.document.uri + this.queue() + } + /** + * Set latest state for task and then update icon accordingly + */ + private setState(state: CodyTaskState): void { + if (this.state !== CodyTaskState.error) { + this.state = state + } + } + + public start(): void { + this.setState(CodyTaskState.pending) + this.output(`Task #${this.id} is currently being processed...`) + } + + public stop(): void { + this.setState(CodyTaskState.done) + this.output(`Task #${this.id} has been completed...`) + } + + public error(text: string = ''): void { + this.setState(CodyTaskState.error) + this.output(`Error for Task #${this.id} - ` + text) + } + + public apply(): void { + this.setState(CodyTaskState.applying) + this.output(`Task #${this.id} is being applied...`) + } + + private queue(): void { + this.setState(CodyTaskState.queued) + this.output(`Task #${this.id} has been added to the queue successfully...`) + } + /** + * Print output to the VS Code Output Channel under Cody AI by Sourcegraph + */ + private output(text: string): void { + this.outputChannel('Cody Fixups:', text) + } + /** + * Return latest selection + */ + public getSelection(): ActiveTextEditorSelection | null { + return this.selection + } + /** + * Return latest selection range + */ + public getSelectionRange(): vscode.Range | vscode.Selection { + return this.selectionRange + } +} diff --git a/client/cody/src/non-stop/TaskController.ts b/client/cody/src/non-stop/TaskController.ts new file mode 100644 index 00000000000..755965ea21c --- /dev/null +++ b/client/cody/src/non-stop/TaskController.ts @@ -0,0 +1,171 @@ +import * as vscode from 'vscode' + +import { ActiveTextEditorSelection } from '@sourcegraph/cody-shared/src/editor' + +import { FixupTask } from './FixupTask' +import { TaskViewProvider, FixupTaskTreeItem } from './TaskViewProvider' +import { CodyTaskState } from './utils' + +type taskID = string + +// This class acts as the factory for Fixup Tasks and handles communication between the Tree View and editor +export class TaskController { + private tasks = new Map() + private readonly taskViewProvider: TaskViewProvider + + private _disposables: vscode.Disposable[] = [] + + constructor() { + // Register commands + this._disposables.push(vscode.commands.registerCommand('cody.fixup.open', id => this.showThisFixup(id))) + this._disposables.push( + vscode.commands.registerCommand('cody.fixup.apply', treeItem => this.applyFixup(treeItem)) + ) + this._disposables.push( + vscode.commands.registerCommand('cody.fixup.apply-by-file', treeItem => this.applyDirFixups(treeItem)) + ) + this._disposables.push( + vscode.commands.registerCommand('cody.fixup.apply-all', treeItem => this.applyAllFixups(treeItem)) + ) + this._disposables.push(vscode.commands.registerCommand('cody.fixup.diff', treeItem => this.showDiff(treeItem))) + // Start the fixup tree view provider + this.taskViewProvider = new TaskViewProvider() + } + + // Adds a new task to the list of tasks + // Then mark it as pending before sending it to the tree view for tree item creation + public add(input: string, selection: ActiveTextEditorSelection): string | null { + const editor = vscode.window.activeTextEditor + if (!editor) { + void vscode.window.showInformationMessage('No active editor found...') + return null + } + + // Create a task and then mark it as start + const task = new FixupTask(input, selection, editor) + task.start() + void vscode.commands.executeCommand('setContext', 'cody.fixup.running', true) + // Save states of the task + this.tasks.set(task.id, task) + this.taskViewProvider.setTreeItem(task) + return task.id + } + + // Replaces content of the file before mark the task as done + // Then update the tree view with the new task state + public stop(taskID: taskID): void { + const task = this.tasks.get(taskID) + if (!task) { + return + } + task.stop() + // Save states of the task + this.tasks.set(task.id, task) + this.taskViewProvider.setTreeItem(task) + void vscode.commands.executeCommand('setContext', 'cody.fixup.running', false) + this.getFilesWithApplicableFixups() + } + + private getFilesWithApplicableFixups(): string[] { + const filePaths: string[] = [] + for (const task of this.tasks.values()) { + if (task.state === CodyTaskState.done) { + filePaths.push(task.documentUri.fsPath) + } + } + void vscode.commands.executeCommand('setContext', 'cody.fixup.filesWithApplicableFixups', filePaths.length > 0) + return filePaths + } + + // Open fsPath at the selected line in editor on tree item click + private showThisFixup(taskID: taskID): void { + const task = this.tasks.get(taskID) + if (!task) { + void vscode.window.showInformationMessage('No fixup was found...') + return + } + // Create vscode Uri from task uri and selection range + void vscode.window.showTextDocument(task.documentUri, { selection: task.selectionRange }) + } + + // TODO: Add support for applying fixup + // Placeholder function for applying fixup + private applyFixup(treeItem?: FixupTaskTreeItem): void { + void vscode.window.showInformationMessage(`Applying fixup for task #${treeItem?.id} is not implemented yet...`) + + if (treeItem?.contextValue === 'task' && treeItem?.id) { + const task = this.tasks.get(treeItem.id) + task?.apply() + return + } + } + + // TODO: Add support for applying fixup to all tasks in a directory + // Placeholder function for applying fixup to all tasks in a directory + private applyDirFixups(treeItem: FixupTaskTreeItem): void { + void vscode.window.showInformationMessage('Applying fixups to a directory is not implemented yet...') + + if (treeItem?.contextValue === 'fsPath') { + for (const task of this.tasks.values()) { + if (task.documentUri.fsPath === treeItem.fsPath && task.state === CodyTaskState.done) { + task.apply() + } + } + } + + this.getFilesWithApplicableFixups() + } + + // TODO: Add support for applying all fixup + // Placeholder function for applying fixup + private applyAllFixups(treeItem?: FixupTaskTreeItem): void { + void vscode.window.showInformationMessage('Applying fixup for all tasks is not implemented yet...') + + // Apply all fixups + for (const task of this.tasks.values()) { + if (task.state === CodyTaskState.done) { + task.apply() + } + } + // Clear task view after applying fixups + // TODO Catch errors + this.reset() + } + + // TODO: Add support for showing diff + // Placeholder function for showing diff + private showDiff(treeItem: FixupTaskTreeItem): string { + if (!treeItem?.id) { + void vscode.window.showInformationMessage('No fixup was found...') + return '' + } + const task = this.tasks.get(treeItem?.id) + // TODO: Implement diff view + void vscode.window.showInformationMessage(`Diff view for task #${task?.id} is not implemented yet...`) + return task?.selection.selectedText || '' + } + + public getTaskView(): TaskViewProvider { + return this.taskViewProvider + } + + public getTasks(): FixupTask[] { + return Array.from(this.tasks.values()) + } + + private reset(): void { + this.tasks = new Map() + this.taskViewProvider.reset() + } + + /** + * Dispose the disposables + */ + public dispose(): void { + this.taskViewProvider.dispose() + for (const disposable of this._disposables) { + disposable.dispose() + } + this._disposables = [] + } +} diff --git a/client/cody/src/non-stop/TaskViewProvider.ts b/client/cody/src/non-stop/TaskViewProvider.ts new file mode 100644 index 00000000000..21585095530 --- /dev/null +++ b/client/cody/src/non-stop/TaskViewProvider.ts @@ -0,0 +1,173 @@ +import * as vscode from 'vscode' + +import { FixupTask } from './FixupTask' +import { CodyTaskState, fixupTaskIcon, getFileNameAfterLastDash } from './utils' + +type taskID = string +type fileName = string +export class TaskViewProvider implements vscode.TreeDataProvider { + /** + * Tree items are mapped by fsPath to taskID + */ + // Add type alias for Map key + private treeNodes = new Map() + private treeItems = new Map() + + private _disposables: vscode.Disposable[] = [] + private _onDidChangeTreeData = new vscode.EventEmitter() + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event + + constructor() { + void vscode.commands.executeCommand('setContext', 'cody.fixup.view.isEmpty', true) + } + + /** + * Refresh the tree view to get the latest data + */ + public refresh(): void { + void vscode.commands.executeCommand('setContext', 'cody.fixup.view.isEmpty', this.treeNodes.size === 0) + this._onDidChangeTreeData.fire() + } + + /** + * Get parents items first + * Then returns children items for each parent item + */ + public getChildren(element?: FixupTaskTreeItem): FixupTaskTreeItem[] { + if (element && element.contextValue === 'fsPath') { + return [...this.treeItems.values()].filter(item => item.fsPath === element.fsPath) + } + + return [...this.treeNodes.values()] + } + + /** + * Create tree item based on provided task + */ + public setTreeItem(task: FixupTask): void { + const treeItem = new FixupTaskTreeItem(task.instruction, task) + this.treeItems.set(task.id, treeItem) + + // Add fsPath to treeNodes + const treeNode = this.treeNodes.get(task.selection.fileName) || new FixupTaskTreeItem(task.selection.fileName) + treeNode.addChildren(task.id, task.state) + this.treeNodes.set(task.selection.fileName, treeNode) + + this.refresh() + } + + /** + * Get individual tree item + */ + public getTreeItem(element: FixupTaskTreeItem): FixupTaskTreeItem { + return element + } + + public removeTreeItemByID(taskID: taskID): void { + const task = this.treeItems.get(taskID) + if (!task) { + return + } + this.treeItems.delete(taskID) + } + + public removeTreeItemsByFileName(fileName: fileName): void { + const task = this.treeNodes.get(fileName) + if (!task) { + return + } + this.treeNodes.delete(fileName) + } + + /** + * Empty the tree view + */ + public reset(): void { + this.treeNodes = new Map() + this.treeItems = new Map() + this.refresh() + } + + /** + * Dispose the disposables + */ + public dispose(): void { + this.reset() + for (const disposable of this._disposables) { + disposable.dispose() + } + this._disposables = [] + } +} + +export class FixupTaskTreeItem extends vscode.TreeItem { + private state: CodyTaskState = CodyTaskState.idle + public fsPath: string + + // state for parent node + private failed = new Set() + private tasks = new Set() + + constructor(label: string, task?: FixupTask) { + super(label) + if (!task) { + this.fsPath = label + this.tooltip = label + this.label = getFileNameAfterLastDash(label) + this.contextValue = 'fsPath' + this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded + this.description = '0 fixups' + return + } + this.state = task.state + this.id = task.id + this.fsPath = task.selection.fileName + this.resourceUri = task.documentUri + this.contextValue = 'task' + this.collapsibleState = vscode.TreeItemCollapsibleState.None + this.tooltip = new vscode.MarkdownString(`Task #${task.id}: ${task.instruction}`, true) + this.command = { command: 'cody.fixup.open', title: 'Go to File', arguments: [task.id] } + + this.updateIconPath() + } + + // For parent node to track children states + public addChildren(taskID: string, state: CodyTaskState): void { + if (this.contextValue !== 'fsPath') { + return + } + this.tasks.add(taskID) + this.description = this.makeNodeDescription(state) + } + + private makeNodeDescription(state: CodyTaskState): string { + const tasksSize = this.tasks.size + const failedSize = this.failed.size + let text = `${tasksSize} ${tasksSize > 1 ? 'fixups' : 'fixup'}` + let ready = tasksSize - failedSize + + switch (state) { + case CodyTaskState.pending: + text += ', 1 running' + ready-- + break + case CodyTaskState.applying: + text += ', 1 applying' + ready-- + break + } + if (failedSize > 0) { + text += `, ${failedSize} failed` + } + if (ready > 0) { + text += `, ${ready} ready` + } + return text + } + + private updateIconPath(): void { + const icon = fixupTaskIcon[this.state].icon + const mode = fixupTaskIcon[this.state].id + this.iconPath = new vscode.ThemeIcon(icon, new vscode.ThemeColor(mode)) + } +} diff --git a/client/cody/src/non-stop/utils.test.ts b/client/cody/src/non-stop/utils.test.ts new file mode 100644 index 00000000000..0265cbb04d5 --- /dev/null +++ b/client/cody/src/non-stop/utils.test.ts @@ -0,0 +1,20 @@ +import { getFileNameAfterLastDash } from './utils' + +// Test for getFileNameAfterLastDash +describe('getFileNameAfterLastDash', () => { + test('gets the last part of the file path after the last slash', () => { + const filePath = '/path/to/file.txt' + const fileName = 'file.txt' + expect(getFileNameAfterLastDash(filePath)).toEqual(fileName) + }) + test('get file name when there is no slash', () => { + const filePath = 'file.txt' + const fileName = 'file.txt' + expect(getFileNameAfterLastDash(filePath)).toEqual(fileName) + }) + test('get file name when there is no extension', () => { + const filePath = 'file' + const fileName = 'file' + expect(getFileNameAfterLastDash(filePath)).toEqual(fileName) + }) +}) diff --git a/client/cody/src/non-stop/utils.ts b/client/cody/src/non-stop/utils.ts new file mode 100644 index 00000000000..8827dbd84af --- /dev/null +++ b/client/cody/src/non-stop/utils.ts @@ -0,0 +1,54 @@ +export enum CodyTaskState { + 'idle' = 0, + 'queued' = 1, + 'pending' = 2, + 'done' = 3, + 'applying' = 4, + 'error' = 5, +} + +export type CodyTaskIcon = { + [key in CodyTaskState]: { + id: string + icon: string + } +} +/** + * Icon for each task state + */ +export const fixupTaskIcon: CodyTaskIcon = { + [CodyTaskState.idle]: { + id: 'idle', + icon: 'smiley', + }, + [CodyTaskState.pending]: { + id: 'pending', + icon: 'sync~spin', + }, + [CodyTaskState.done]: { + id: 'done', + icon: 'issue-closed', + }, + [CodyTaskState.error]: { + id: 'error', + icon: 'stop', + }, + [CodyTaskState.queued]: { + id: 'queue', + icon: 'clock', + }, + [CodyTaskState.applying]: { + id: 'applying', + icon: 'sync~spin', + }, +} +/** + * Get the last part of the file path after the last slash + */ +export function getFileNameAfterLastDash(filePath: string): string { + const lastDashIndex = filePath.lastIndexOf('/') + if (lastDashIndex === -1) { + return filePath + } + return filePath.slice(lastDashIndex + 1) +} diff --git a/client/cody/src/test-support.ts b/client/cody/src/test-support.ts index 374c7501c18..beccc67e090 100644 --- a/client/cody/src/test-support.ts +++ b/client/cody/src/test-support.ts @@ -1,6 +1,7 @@ import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages' import { ChatViewProvider } from './chat/ChatViewProvider' +import { FixupTask } from './non-stop/FixupTask' // A one-slot channel which lets readers block on a value being // available from a writer. Tests use this to wait for the @@ -41,4 +42,8 @@ export class TestSupport { public async chatTranscript(): Promise { return (await this.chatViewProvider.get()).transcriptForTesting(this) } + + public async fixupTasks(): Promise { + return (await this.chatViewProvider.get()).fixupTasksForTesting(this) + } } diff --git a/client/cody/test/e2e/BUILD.bazel b/client/cody/test/e2e/BUILD.bazel index e318f8185cd..c4da85e0f3d 100644 --- a/client/cody/test/e2e/BUILD.bazel +++ b/client/cody/test/e2e/BUILD.bazel @@ -13,6 +13,7 @@ ts_project( "helpers.ts", "history.test.ts", "inline-assist.test.ts", + "task-controller.test.ts", ], tsconfig = "//client/cody:tsconfig", deps = [ diff --git a/client/cody/test/e2e/auth.test.ts b/client/cody/test/e2e/auth.test.ts index 2f7e0d75a57..e032acc4b9b 100644 --- a/client/cody/test/e2e/auth.test.ts +++ b/client/cody/test/e2e/auth.test.ts @@ -15,8 +15,13 @@ test('requires a valid auth token and allows logouts', async ({ page, sidebar }) await sidebar.getByRole('textbox', { name: 'Access Token (docs)' }).fill(VALID_TOKEN) await sidebar.getByRole('button', { name: 'Sign In' }).click() + // Collapse the task tree view + await page.getByRole('button', { name: 'Fixups Section' }).click() + await expect(sidebar.getByText("Hello! I'm Cody.")).toBeVisible() + await page.getByRole('button', { name: 'Chat Section' }).hover() + await page.click('[aria-label="Cody: Settings"]') await sidebar.getByRole('button', { name: 'Logout' }).click() diff --git a/client/cody/test/e2e/common.ts b/client/cody/test/e2e/common.ts index 2be9b3bbcdd..444443b2ff5 100644 --- a/client/cody/test/e2e/common.ts +++ b/client/cody/test/e2e/common.ts @@ -3,12 +3,17 @@ import { Frame, Locator, Page, expect } from '@playwright/test' import { SERVER_URL, VALID_TOKEN } from '../fixtures/mock-server' // Sign into Cody with valid auth from the sidebar -export const sidebarSignin = async (sidebar: Frame): Promise => { +export const sidebarSignin = async (page: Page, sidebar: Frame): Promise => { await sidebar.getByRole('textbox', { name: 'Sourcegraph Instance URL' }).fill(SERVER_URL) await sidebar.getByRole('textbox', { name: 'Access Token (docs)' }).fill(VALID_TOKEN) await sidebar.getByRole('button', { name: 'Sign In' }).click() + // Collapse the task tree view + await page.getByRole('button', { name: 'Fixups Section' }).click() + await expect(sidebar.getByText("Hello! I'm Cody.")).toBeVisible() + + await page.getByRole('button', { name: 'Chat Section' }).hover() } // Selector for the Explorer button in the sidebar that would match on Mac and Linux diff --git a/client/cody/test/e2e/history.test.ts b/client/cody/test/e2e/history.test.ts index 83fb5a31158..79e8383ba7e 100644 --- a/client/cody/test/e2e/history.test.ts +++ b/client/cody/test/e2e/history.test.ts @@ -10,8 +10,13 @@ test('checks for the chat history and new session', async ({ page, sidebar }) => await sidebar.getByRole('textbox', { name: 'Access Token (docs)' }).fill(VALID_TOKEN) await sidebar.getByRole('button', { name: 'Sign In' }).click() + // Collapse the task tree view + await page.getByRole('button', { name: 'Fixups Section' }).click() + await expect(sidebar.getByText("Hello! I'm Cody.")).toBeVisible() + await page.getByRole('button', { name: 'Chat Section' }).hover() + await page.click('[aria-label="Cody: Chat History"]') await expect(sidebar.getByText('Chat History')).toBeVisible() diff --git a/client/cody/test/e2e/inline-assist.test.ts b/client/cody/test/e2e/inline-assist.test.ts index 9c23f93b4e7..1b54b3424ca 100644 --- a/client/cody/test/e2e/inline-assist.test.ts +++ b/client/cody/test/e2e/inline-assist.test.ts @@ -5,7 +5,7 @@ import { test } from './helpers' test('start a fixup job from inline assist with valid auth', async ({ page, sidebar }) => { // Sign into Cody - await sidebarSignin(sidebar) + await sidebarSignin(page, sidebar) // Open the Explorer view from the sidebar await sidebarExplorer(page).click() diff --git a/client/cody/test/e2e/task-controller.test.ts b/client/cody/test/e2e/task-controller.test.ts new file mode 100644 index 00000000000..788d7b3dfbe --- /dev/null +++ b/client/cody/test/e2e/task-controller.test.ts @@ -0,0 +1,67 @@ +import { expect } from '@playwright/test' + +import { sidebarExplorer, sidebarSignin } from './common' +import { test } from './helpers' + +test('task tree view for non-stop cody', async ({ page, sidebar }) => { + // Sign into Cody + await sidebarSignin(page, sidebar) + + // Open the Explorer view from the sidebar + await sidebarExplorer(page).click() + + // Select the second files from the tree view, which is the index.html file + await page.locator('.monaco-highlighted-label').nth(2).click() + + // Bring the cody sidebar to the foreground + await page.click('[aria-label="Sourcegraph Cody"]') + + // Expand the task tree view + await page.getByRole('button', { name: 'Fixups Section' }).click() + + // Open the command palette by clicking on the Cody Icon + // Expect to see fail to start because no text was selected + await page.getByRole('button', { name: /Cody: Fixup.*/ }).click() + await expect(page.getByText(/^Cody Fixups: Failed to start.*/)).toBeVisible() + + // Find the text hello cody, and then highlight the text + await page.getByText('Hello Cody').click() + + // Hightlight the whole line + await page.keyboard.down('Shift') + await page.keyboard.press('ArrowDown') + + // Open the command palette by clicking on the Cody Icon + await page.getByRole('button', { name: /Cody: Fixup.*/ }).click() + // Type in the instruction for fixup + await page.keyboard.type('replace hello with goodbye') + // Press enter to submit the fixup + await page.keyboard.press('Enter') + + // Expect to see the fixup instruction in the task tree view + await expect(page.getByText('1 fixup, 1 ready')).toBeVisible() + await expect(page.getByText('No pending Cody fixups')).not.toBeVisible() + + // Diff view button + await page.locator('a').filter({ hasText: 'replace hello with goodbye' }).click() + await page.getByRole('button', { name: 'Cody: Show diff for fixup' }).click() + await expect(page.getByText(/^Diff view for task.*/)).toBeVisible() + + // Apply fixup button on Click + await page.locator('a').filter({ hasText: 'replace hello with goodbye' }).click() + await page.getByRole('button', { name: 'Cody: Apply fixup' }).click() + await expect(page.getByText(/^Applying fixup for task.*/)).toBeVisible() + + // Close the file tab and then clicking on the tree item again should open the file again + await page.getByRole('button', { name: /^Close.*/ }).click() + await expect(page.getByText('Hello Cody')).not.toBeVisible() + await page.locator('a').filter({ hasText: 'replace hello with goodbye' }).click() + await expect(page.getByText('Hello Cody')).toBeVisible() + + // Collapse the task tree view + await page.getByRole('button', { name: 'Fixups Section' }).click() + await expect(page.getByText('replace hello with good bye')).not.toBeVisible() + + // The chat view should be visible again + await expect(sidebar.getByText(/^Check your doc.*/)).toBeVisible() +}) diff --git a/client/cody/test/fixtures/mock-server.ts b/client/cody/test/fixtures/mock-server.ts index 6cb70af61b0..f1c6c05dffe 100644 --- a/client/cody/test/fixtures/mock-server.ts +++ b/client/cody/test/fixtures/mock-server.ts @@ -1,5 +1,17 @@ import express from 'express' +// create interface for the request +interface MockRequest { + headers: { + authorization: string + } + body: { + messages: { + text: string + }[] + } +} + const SERVER_PORT = 49300 export const SERVER_URL = 'http://localhost:49300' @@ -17,7 +29,11 @@ export async function run(around: () => Promise): Promise { app.post('/.api/completions/stream', (req, res) => { // TODO: Filter streaming response - const response = req.body.messages[2].text.includes('') ? responses.fixup : responses.chat + // TODO: Handle multiple messages + // Ideas from Dom - see if we could put something in the test request itself where we tell it what to respond with + // or have a method on the server to send a set response the next time it sees a trigger word in the request. + const request = req as MockRequest + const response = request.body.messages[2].text.includes('') ? responses.fixup : responses.chat res.send(`event: completion\ndata: {"completion": ${JSON.stringify(response)}}\n\nevent: done\ndata: {}\n\n`) }) diff --git a/client/cody/test/integration/BUILD.bazel b/client/cody/test/integration/BUILD.bazel index cffb3167308..7d58ec99278 100644 --- a/client/cody/test/integration/BUILD.bazel +++ b/client/cody/test/integration/BUILD.bazel @@ -28,6 +28,7 @@ ts_project( "index.ts", "main.ts", "recipes.test.ts", + "task-controller.test.ts", ], tsconfig = ":tsconfig", deps = [ diff --git a/client/cody/test/integration/helpers.ts b/client/cody/test/integration/helpers.ts index 50c276fbf4d..6c5a4660e3f 100644 --- a/client/cody/test/integration/helpers.ts +++ b/client/cody/test/integration/helpers.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode' import { ChatMessage } from '@sourcegraph/cody-shared/src/chat/transcript/messages' import { ExtensionApi } from '../../src/extension-api' +import { FixupTask } from '../../src/non-stop/FixupTask' import * as mockServer from '../fixtures/mock-server' /** @@ -25,7 +26,7 @@ export async function beforeIntegrationTest(): Promise { // Configure extension. const config = vscode.workspace.getConfiguration() await config.update('cody.serverEndpoint', mockServer.SERVER_URL) - await ensureExecuteCommand('cody.set-access-token', ['test-token']) + await ensureExecuteCommand('cody.set-access-token', [mockServer.VALID_TOKEN]) } /** @@ -76,3 +77,21 @@ export async function getTranscript(index: number): Promise { assert.ok(transcript) return transcript[index] } + +export async function getFixupTasks(): Promise { + const api = getExtensionAPI() + const testSupport = api.exports.testing + assert.ok(testSupport) + + let fixups: FixupTask[] | undefined + + await waitUntil(async () => { + if (!api.isActive || !api.exports.testing) { + return false + } + fixups = await getExtensionAPI().exports.testing?.fixupTasks() + return true + }) + assert.ok(fixups) + return fixups || [] +} diff --git a/client/cody/test/integration/task-controller.test.ts b/client/cody/test/integration/task-controller.test.ts new file mode 100644 index 00000000000..72570a8ce6e --- /dev/null +++ b/client/cody/test/integration/task-controller.test.ts @@ -0,0 +1,89 @@ +import * as assert from 'assert' + +import * as vscode from 'vscode' + +import { ChatViewProvider } from '../../src/chat/ChatViewProvider' + +import { afterIntegrationTest, beforeIntegrationTest, getExtensionAPI, getFixupTasks, getTranscript } from './helpers' + +async function getChatViewProvider(): Promise { + const chatViewProvider = await getExtensionAPI().exports.testing?.chatViewProvider.get() + assert.ok(chatViewProvider) + return chatViewProvider +} + +suite('Cody Fixup Task Controller', function () { + this.beforeEach(() => beforeIntegrationTest()) + this.afterEach(() => afterIntegrationTest()) + + let textEditor = vscode.window.activeTextEditor + + // Run the non-stop recipe to create a new task before every test + this.beforeEach(async () => { + await vscode.commands.executeCommand('cody.chat.focus') + const chatView = await getChatViewProvider() + + // Open index.html + assert.ok(vscode.workspace.workspaceFolders) + + const indexUri = vscode.Uri.parse(`${vscode.workspace.workspaceFolders[0].uri.toString()}/index.html`) + textEditor = await vscode.window.showTextDocument(indexUri) + + // Select the "title" tags to run the recipe on + textEditor.selection = new vscode.Selection(6, 0, 7, 0) + + // Brings up the vscode input box + await chatView.executeRecipe('non-stop', 'Replace hello with goodbye', false) + + // Check the chat transcript contains markdown + const humanMessage = await getTranscript(0) + + assert.match(humanMessage.displayText || '', /^Cody Fixups: Replace hello with goodbye/) + assert.match((await getTranscript(1)).displayText || '', /^Check your document for updates from Cody/) + }) + + test('task controller', async () => { + if (textEditor === undefined) { + assert.fail('editor is undefined') + } + // Check the Fixup Tasks from Task Controller contains the new task + const tasks = await getFixupTasks() + assert.strictEqual(tasks.length, 1) + + assert.match(tasks[0].instruction, /^Replace hello with goodbye/) + + // Get selection text from editor whch should match Hello Cody + // TODO: Update to Goodbye Cody after we have implemented the replace method. Right now we are marking it as done + const selectionText = textEditor.document.getText(textEditor.selection).trim() + assert.match(selectionText, /^Hello Cody<\/title>/) + + // Run the apply command should remove all tasks from the task controller + await vscode.commands.executeCommand('cody.fixup.apply') + assert.strictEqual((await getFixupTasks()).length, 1) + }) + + test('show this fixup', async () => { + // Check the Fixup Tasks from Task Controller contains the new task + const tasks = await getFixupTasks() + assert.strictEqual(tasks.length, 2) + + // Switch to a different file + const mainJavaUri = vscode.Uri.parse(`${vscode.workspace.workspaceFolders?.[0].uri.toString()}/Main.java`) + await vscode.workspace.openTextDocument(mainJavaUri) + + // Run show command to open fixup file with range selected + await vscode.commands.executeCommand('cody.fixup.open', tasks[0].id) + + const newEditor = vscode.window.activeTextEditor + assert.strictEqual(newEditor, textEditor) + }) + + test('apply fixups', async () => { + const tasks = await getFixupTasks() + assert.strictEqual(tasks.length, 3) + + // Run the apply command should remove all tasks from the task controller + await vscode.commands.executeCommand('cody.fixup.apply-all') + assert.strictEqual((await getFixupTasks()).length, 0) + }) +})