Cody: Add Task View Base for Non-Stop Cody (#52282)

Adds skeleton tree view for Non-Stop Cody
This commit is contained in:
Beatrix 2023-05-26 17:31:11 -07:00 committed by GitHub
parent ce3bccdf3d
commit 72ee67fe29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1001 additions and 16 deletions

View File

@ -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",

View File

@ -13,7 +13,7 @@ export class Fixup implements Recipe {
public async getInteraction(humanChatInput: string, context: RecipeContext): Promise<Interaction | null> {
// 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

View File

@ -13,7 +13,7 @@ export class InlineChat implements Recipe {
public id: RecipeID = 'inline-chat'
public async getInteraction(humanChatInput: string, context: RecipeContext): Promise<Interaction | null> {
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

View File

@ -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<Interaction | null> {
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<ContextMessage[]> {
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 <selection> 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 <selection> tags. I only want to see the code within <selection>.
Do not move code from outside the selection into the selection in your reply.
Do not remove code inside the <selection> tags that might be being used by the code outside the <selection> tags.
Do not enclose replacement code with tags other than the <selection> tags.
Do not enclose your answer with any markdown.
Only return provide me the replacement <selection> and nothing else.
If it doesn't make sense, you do not need to provide <selection>.
\`\`\`
{truncateTextStart}<selection>{selectedText}</selection>{truncateFollowingText}
\`\`\`
Additional Instruction:
- {humanInput}
- {responseMultiplexerPrompt}
`
}

View File

@ -28,6 +28,7 @@ export type RecipeID =
| 'release-notes'
| 'inline-chat'
| 'next-questions'
| 'non-stop'
| 'optimize-code'
export interface Recipe {

View File

@ -7,11 +7,12 @@ export interface Configuration {
debugFilter: RegExp | null
debugVerbose: boolean
useContext: ConfigurationUseContext
customHeaders: Record<string, string>
experimentalSuggest: boolean
experimentalChatPredictions: boolean
experimentalInline: boolean
experimentalGuardrails: boolean
customHeaders: Record<string, string>
experimentalNonStop: boolean
}
export interface ConfigurationWithAccessToken extends Configuration {

View File

@ -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

View File

@ -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",
],

View File

@ -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",

View File

@ -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()

View File

@ -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(),
]

View File

@ -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: /.*/,

View File

@ -41,6 +41,7 @@ export function getConfiguration(config: Pick<vscode.WorkspaceConfiguration, 'ge
experimentalChatPredictions: config.get('cody.experimental.chatPredictions', isTesting),
experimentalInline: config.get('cody.experimental.inline', isTesting),
experimentalGuardrails: config.get('cody.experimental.guardrails', isTesting),
experimentalNonStop: config.get('cody.experimental.nonStop', isTesting),
}
}

View File

@ -8,10 +8,16 @@ import {
} from '@sourcegraph/cody-shared/src/editor'
import { SURROUNDING_LINES } from '@sourcegraph/cody-shared/src/prompt/constants'
import { TaskController } from '../non-stop/TaskController'
import { InlineController } from '../services/InlineController'
export class VSCodeEditor implements Editor {
constructor(public controller: InlineController) {}
constructor(
public controllers: {
inline: InlineController
task: TaskController
}
) {}
public getWorkspaceRootPath(): string | null {
const uri = vscode.window.activeTextEditor?.document?.uri
@ -40,7 +46,7 @@ export class VSCodeEditor implements Editor {
}
public getActiveTextEditorSelection(): ActiveTextEditorSelection | null {
if (this.controller.isInProgress) {
if (this.controllers.inline.isInProgress) {
return null
}
const activeEditor = this.getActiveTextEditorInstance()
@ -115,8 +121,8 @@ export class VSCodeEditor implements Editor {
public async replaceSelection(fileName: string, selectedText: string, replacement: string): Promise<void> {
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) {

View File

@ -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),

View File

@ -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
}
}

View File

@ -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<taskID, FixupTask>()
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<taskID, FixupTask>()
this.taskViewProvider.reset()
}
/**
* Dispose the disposables
*/
public dispose(): void {
this.taskViewProvider.dispose()
for (const disposable of this._disposables) {
disposable.dispose()
}
this._disposables = []
}
}

View File

@ -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<FixupTaskTreeItem> {
/**
* Tree items are mapped by fsPath to taskID
*/
// Add type alias for Map key
private treeNodes = new Map<fileName, FixupTaskTreeItem>()
private treeItems = new Map<taskID, FixupTaskTreeItem>()
private _disposables: vscode.Disposable[] = []
private _onDidChangeTreeData = new vscode.EventEmitter<FixupTaskTreeItem | undefined | void>()
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<fileName, FixupTaskTreeItem>()
this.treeItems = new Map<taskID, FixupTaskTreeItem>()
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<string>()
private tasks = new Set<string>()
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))
}
}

View File

@ -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)
})
})

View File

@ -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)
}

View File

@ -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<ChatMessage[]> {
return (await this.chatViewProvider.get()).transcriptForTesting(this)
}
public async fixupTasks(): Promise<FixupTask[]> {
return (await this.chatViewProvider.get()).fixupTasksForTesting(this)
}
}

View File

@ -13,6 +13,7 @@ ts_project(
"helpers.ts",
"history.test.ts",
"inline-assist.test.ts",
"task-controller.test.ts",
],
tsconfig = "//client/cody:tsconfig",
deps = [

View File

@ -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()

View File

@ -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<void> => {
export const sidebarSignin = async (page: Page, sidebar: Frame): Promise<void> => {
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

View File

@ -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()

View File

@ -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()

View File

@ -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('<title>Hello Cody</title>').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('<title>Hello Cody</title>')).not.toBeVisible()
await page.locator('a').filter({ hasText: 'replace hello with goodbye' }).click()
await expect(page.getByText('<title>Hello Cody</title>')).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()
})

View File

@ -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<T>(around: () => Promise<T>): Promise<T> {
app.post('/.api/completions/stream', (req, res) => {
// TODO: Filter streaming response
const response = req.body.messages[2].text.includes('<selection>') ? 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('<selection>') ? responses.fixup : responses.chat
res.send(`event: completion\ndata: {"completion": ${JSON.stringify(response)}}\n\nevent: done\ndata: {}\n\n`)
})

View File

@ -28,6 +28,7 @@ ts_project(
"index.ts",
"main.ts",
"recipes.test.ts",
"task-controller.test.ts",
],
tsconfig = ":tsconfig",
deps = [

View File

@ -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<void> {
// 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<ChatMessage> {
assert.ok(transcript)
return transcript[index]
}
export async function getFixupTasks(): Promise<FixupTask[]> {
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 || []
}

View File

@ -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<ChatViewProvider> {
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 <title>Hello Cody</title>
// TODO: Update to <title>Goodbye Cody</title> 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, /^<title>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)
})
})