From 5f154777f6a7728a4e13e086aa8f438988f95e4f Mon Sep 17 00:00:00 2001 From: "bobo.yang" Date: Thu, 27 Apr 2023 14:07:46 +0800 Subject: [PATCH] release 0.0.1 --- .gitignore | 1 + README.md | 69 +++++--------- media/chatPanel.css | 16 ++++ media/chatPanel.html | 5 ++ media/chatUI.js | 83 +++++++++++++++-- media/clipboard.js | 42 ++++++++- media/main.js | 157 +++++++++++++++++++++++--------- package-lock.json | 35 ++++++++ package.json | 42 ++++++++- src/__test__/devchat.test.ts | 38 -------- src/applyCode.ts | 46 ++++++++++ src/chatPanel.ts | 11 ++- src/devchat.ts | 168 +++++++++++++++++++++++++++-------- src/dtm.ts | 41 +++++++++ src/extension.ts | 54 +++++++++++ src/messageHandler.ts | 95 +++++++++++++++++++- 16 files changed, 721 insertions(+), 182 deletions(-) delete mode 100644 src/__test__/devchat.test.ts create mode 100644 src/applyCode.ts create mode 100644 src/dtm.ts diff --git a/.gitignore b/.gitignore index 5cfb456..badad5f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .vscode-test/ *.vsix .env +.chatconfig.json diff --git a/README.md b/README.md index aa26384..a908522 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,46 @@ -# devchat README +# DevChat -This is the README for your extension "devchat". After writing up a brief description, we recommend including the following sections. +DevChat is a programming assistant that helps developers, testers, and operations staff automate many tasks. ## Features -Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. - -For example if there is an image subfolder under your extension project workspace: - -\!\[feature X\]\(images/feature-x.png\) - -> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. +- Generate code driven by questions +- Create smarter commit messages ## Requirements -If you have any requirements or dependencies, add a section describing those and how to install and configure them. +- Python package: `pip install devchat` +- System package: `apt install dtm` +- Set your `OPENAI_API_KEY` environment variable -## Extension Settings +## Installation -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. +Search for "DevChat" in the Visual Studio Code marketplace and click the "Install" button. -For example: +## Usage -This extension contributes the following settings: +Enter "DevChat" in the command palette to open the interactive page. -* `myExtension.enable`: Enable/disable this extension. -* `myExtension.thing`: Set to `blah` to do something. +## Settings + +*Coming soon* ## Known Issues -Calling out known issues can help limit users opening duplicate issues against your extension. +*Coming soon* ## Release Notes -Users appreciate release notes as you update your extension. +*Coming soon* -### 1.0.0 +## Contributing -Initial release of ... +*Coming soon* -### 1.0.1 +## License -Fixed issue #. +*Specify the license here* -### 1.1.0 +## Contact Information -Added features X, Y, and Z. - ---- - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) - -## Working with Markdown - -You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: - -* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). -* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). -* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. - -## For more information - -* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) -* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) - -**Enjoy!** +*Provide your contact information, such as an email address or a link to a GitHub repository* diff --git a/media/chatPanel.css b/media/chatPanel.css index 510ce01..c9c4ea1 100644 --- a/media/chatPanel.css +++ b/media/chatPanel.css @@ -47,6 +47,22 @@ pre { overflow: auto; } +.context-menu { + position: absolute; + display: none; + background-color: white; + border: 1px solid #ccc; + padding: 8px; + z-index: 10; +} +.context-menu-item { + cursor: pointer; + margin-bottom: 4px; +} +.context-menu-item:last-child { + margin-bottom: 0; +} + .copy-button { background-color: #007acc; color: white; diff --git a/media/chatPanel.html b/media/chatPanel.html index 6f544cd..e107a77 100644 --- a/media/chatPanel.html +++ b/media/chatPanel.html @@ -26,6 +26,11 @@ +
+ + +
+ diff --git a/media/chatUI.js b/media/chatUI.js index b957ad6..79a32db 100644 --- a/media/chatUI.js +++ b/media/chatUI.js @@ -1,11 +1,68 @@ +// chatUI.js + const vscode_api = acquireVsCodeApi(); const md = new markdownit(); -function addMessageToUI(role, content) { +function getLastBotMessageItem(messagesContainer) { + const lastMessage = messagesContainer.lastElementChild; + + if (lastMessage && lastMessage.classList.contains('message-item')) { + const lastMessageIcon = lastMessage.querySelector('i'); + if (lastMessageIcon && lastMessageIcon.classList.contains('fa-robot')) { + return lastMessage; + } + } + + return null; +} + +function initButtonForCodeBlock(codeBlocks) { + codeBlocks.forEach(block => { + block.classList.add('code-block'); + }); + + initClipboard(codeBlocks, (patchContent) => { + postVSCodeMessage({ + command: 'block_apply', + content: patchContent, + }); + }, (codeContent) => { + postVSCodeMessage({ + command: 'code_apply', + content: codeContent, + }); + }, (codeContent) => { + postVSCodeMessage({ + command: 'code_file_apply', + content: codeContent, + }); + }); +} + +function addMessageToUI(role, content, partial = false) { // Create a MarkdownIt instance for rendering markdown content const messagesContainer = document.getElementById('messages-container'); - // Create a container for the message item + // Render the markdown content inside the message content container + const renderedContent = md.render(content); + + let lastBotMessage = getLastBotMessageItem(messagesContainer); + let lastMessageContent; + + if (role == 'bot' && lastBotMessage != null) { + lastMessageContent = lastBotMessage.querySelector('.message-content'); + lastMessageContent.innerHTML = renderedContent; + + if (!partial) { + // Find any code blocks in the rendered content and add a class to style them + const codeBlocks = lastMessageContent.querySelectorAll('pre > code'); + + // Initialize the Apply Patch functionality + initButtonForCodeBlock(codeBlocks); + } + return; + } + const messageItem = document.createElement('div'); messageItem.classList.add('message-item'); @@ -17,17 +74,13 @@ function addMessageToUI(role, content) { // Create a container for the message content const messageContent = document.createElement('div'); messageContent.classList.add('message-content'); - - // Render the markdown content inside the message content container - const renderedContent = md.render(content); messageContent.innerHTML = renderedContent; // Find any code blocks in the rendered content and add a class to style them const codeBlocks = messageContent.querySelectorAll('pre > code'); - codeBlocks.forEach(block => { - block.classList.add('code-block'); - }); - initClipboard(codeBlocks); + + // Initialize the Apply Patch functionality + initButtonForCodeBlock(codeBlocks); messageItem.appendChild(senderIcon); messageItem.appendChild(messageContent); @@ -121,4 +174,16 @@ function processMessage(message) { function processMessageUI(message) { addMessageToUI('user', message); processMessage(message); +} + +// Function to request history messages from the extension +function requestHistoryMessages() { + // Send a message to the extension with the 'historyMessages' command + vscode_api.postMessage({ + command: 'historyMessages', + }); +} + +function postVSCodeMessage(message) { + vscode_api.postMessage(message); } \ No newline at end of file diff --git a/media/clipboard.js b/media/clipboard.js index ed44356..a58a5fd 100644 --- a/media/clipboard.js +++ b/media/clipboard.js @@ -1,5 +1,13 @@ -function initClipboard(codeBlocks) { +// clipboard.js + + +function initClipboard(codeBlocks, onApplyButtonClick, onApplyCodeButtonClick, onApplyCodeFileButtonClick) { codeBlocks.forEach(block => { + const contentSpan = document.createElement('span'); + contentSpan.innerHTML = block.innerHTML; + block.innerHTML = ''; + block.appendChild(contentSpan); + const copyButton = document.createElement('button'); copyButton.classList.add('copy-button'); copyButton.innerText = 'Copy'; @@ -7,7 +15,7 @@ function initClipboard(codeBlocks) { copyButton.addEventListener('click', () => { // Copy the message text to the clipboard - navigator.clipboard.writeText(block.textContent); + navigator.clipboard.writeText(contentSpan.textContent); // Change the button text temporarily to show that the text has been copied copyButton.textContent = 'Copied!'; @@ -20,5 +28,35 @@ function initClipboard(codeBlocks) { copyButton.appendChild(copyIcon); }, 1000); }); + + // Add 'Apply' button + const applyButton = document.createElement('button'); + applyButton.classList.add('apply-button'); + applyButton.innerText = 'Apply Patch'; + block.appendChild(applyButton); + + applyButton.addEventListener('click', () => { + onApplyButtonClick(contentSpan.textContent); + }); + + // Add 'Apply' button + const applyCodeButton = document.createElement('button'); + applyCodeButton.classList.add('apply-button'); + applyCodeButton.innerText = 'Insert Code'; + block.appendChild(applyCodeButton); + + applyCodeButton.addEventListener('click', () => { + onApplyCodeButtonClick(contentSpan.textContent); + }); + + // Add 'Apply' button + const applyCodeFileButton = document.createElement('button'); + applyCodeFileButton.classList.add('apply-button'); + applyCodeFileButton.innerText = 'Relace File'; + block.appendChild(applyCodeFileButton); + + applyCodeFileButton.addEventListener('click', () => { + onApplyCodeFileButtonClick(contentSpan.textContent); + }); }); } \ No newline at end of file diff --git a/media/main.js b/media/main.js index 1b34ab9..5462d4f 100644 --- a/media/main.js +++ b/media/main.js @@ -1,46 +1,123 @@ +// main.js + (function () { - // Get DOM elements for user interaction and message display - const messageInput = document.getElementById('message-input'); - const sendButton = document.getElementById('send-button'); + // Get DOM elements for user interaction and message display + const messagesContainer = document.getElementById('messages-container'); + const messageInput = document.getElementById('message-input'); + const sendButton = document.getElementById('send-button'); + const contextMenu = document.getElementById('context-menu'); + const menuItem1 = document.getElementById('menu-item-1'); + const menuItem2 = document.getElementById('menu-item-2'); + let selectedText = ''; + + // Initialize input resizing + initInputResizing(); + + function hideContextMenu() { + contextMenu.style.display = 'none'; + } + + function getSelectedText() { + const selection = window.getSelection(); + return selection.toString(); + } - // Initialize input resizing - initInputResizing(); + messagesContainer.addEventListener('contextmenu', (event) => { + event.preventDefault(); + selectedText = getSelectedText(); + contextMenu.style.display = 'block'; + contextMenu.style.left = event.pageX + 'px'; + contextMenu.style.top = event.pageY + 'px'; + }); - // Event listener for receiving messages from the extension - window.addEventListener('message', (event) => { - const message = event.data; - switch (message.command) { - case 'receiveMessage': - // Add the received message to the chat UI as a bot message - addMessageToUI('bot', message.text); - break; - } + document.addEventListener('click', hideContextMenu); + + menuItem1.addEventListener('click', () => { + postVSCodeMessage({ + command: 'code_apply', + content: selectedText, }); + hideContextMenu(); + }); - // Event listener for the send button - sendButton.addEventListener('click', () => { - const message = messageInput.value; - if (message) { - // Add the user's message to the chat UI - addMessageToUI('user', message); - - // Clear the input field - messageInput.value = ''; - - // Process and send the message to the extension - processMessage(message); + menuItem2.addEventListener('click', () => { + navigator.clipboard.writeText(selectedText); + hideContextMenu(); + }); + + // Event listener for receiving messages from the extension + window.addEventListener('message', (event) => { + const message = event.data; + switch (message.command) { + case 'receiveMessage': + // Add the received message to the chat UI as a bot message + addMessageToUI('bot', message.text); + break; + case 'receiveMessagePartial': + // Add the received message to the chat UI as a bot message + addMessageToUI('bot', message.text, true); + break; + case 'loadHistoryMessages': + loadHistoryMessages(message.entries); + break; + case 'file_select': + addFileToMessageInput(message.filePath); + break; + case 'code_select': + addCodeToMessageInput(message.codeBlock); + break + case 'ask_ai': + message_text = message.codeBlock + "\n" + message.question; + processMessageUI(message_text) + break; + } + }); + + // Event listener for the send button + sendButton.addEventListener('click', () => { + const message = messageInput.value; + if (message) { + // Add the user's message to the chat UI + addMessageToUI('user', message); + + // Clear the input field + messageInput.value = ''; + + // Process and send the message to the extension + processMessage(message); + } + }); + + // Event listener for the Enter key in the message input field + messageInput.addEventListener('keypress', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const message = messageInput.value.trim(); + if (message !== '') { + sendButton.click(); } - }); - - // Event listener for the Enter key in the message input field - messageInput.addEventListener('keypress', function (e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - const message = messageInput.value.trim(); - if (message !== '') { - sendButton.click(); - } - } - }); - })(); - \ No newline at end of file + } + }); + + function addFileToMessageInput(filePath) { + const formattedPath = `[context|${filePath}] `; + messageInput.value = formattedPath + messageInput.value; + messageInput.focus(); + } + + function addCodeToMessageInput(codeBlock) { + messageInput.value += "\n" + codeBlock + "\n"; + messageInput.focus(); + } + + // Request history messages when the web view is created and shown + requestHistoryMessages(); +})(); + +// Function to load history messages from the extension +function loadHistoryMessages(entries) { + entries.forEach((entry) => { + addMessageToUI('user', entry.message); + addMessageToUI('bot', entry.response); + }); +} diff --git a/package-lock.json b/package-lock.json index 76f2a88..d607a9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "node-fetch": "^3.3.1", "nonce": "^1.0.4", "openai": "^3.2.1", + "quote": "^0.4.0", + "shell-escape": "^0.2.0", "uuid": "^9.0.0" }, "devDependencies": { @@ -20,6 +22,7 @@ "@types/jest": "^29.5.1", "@types/mocha": "^10.0.1", "@types/node": "16.x", + "@types/shell-escape": "^0.2.1", "@types/vscode": "^1.77.0", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", @@ -1477,6 +1480,12 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, + "node_modules/@types/shell-escape": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@types/shell-escape/-/shell-escape-0.2.1.tgz", + "integrity": "sha512-95hZXmBvwtvsLMPefKT9xquUSAJXsVDUaipyUiYoYi3ZdLhZ3w30w230Ugs96IdoJQb5ECvj0D82Jj/op00qWQ==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -5341,6 +5350,11 @@ } ] }, + "node_modules/quote": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/quote/-/quote-0.4.0.tgz", + "integrity": "sha512-KHp3y3xDjuBhRx+tYKOgzPnVHMRlgpn2rU450GcU4PL24r1H6ls/hfPrxDwX2pvYMlwODHI2l8WwgoV69x5rUQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5615,6 +5629,11 @@ "node": ">=8" } }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -7570,6 +7589,12 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, + "@types/shell-escape": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@types/shell-escape/-/shell-escape-0.2.1.tgz", + "integrity": "sha512-95hZXmBvwtvsLMPefKT9xquUSAJXsVDUaipyUiYoYi3ZdLhZ3w30w230Ugs96IdoJQb5ECvj0D82Jj/op00qWQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -10423,6 +10448,11 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "quote": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/quote/-/quote-0.4.0.tgz", + "integrity": "sha512-KHp3y3xDjuBhRx+tYKOgzPnVHMRlgpn2rU450GcU4PL24r1H6ls/hfPrxDwX2pvYMlwODHI2l8WwgoV69x5rUQ==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -10622,6 +10652,11 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==" + }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", diff --git a/package.json b/package.json index f90afd6..661b071 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,44 @@ "commands": [ { "command": "devchat.openChatPanel", - "title": "Open Chat Panel" + "title": "DevChat" + }, + { + "command": "devchat.addConext", + "title": "add to DevChat" + }, + { + "command": "devchat.askForCode", + "title": "add to DevChat" + }, + { + "command": "devchat.askForFile", + "title": "add to DevChat" } - ] + ], + "menus": { + "explorer/context": [ + { + "when": "resourceLangId != 'git'", + "command": "devchat.addConext", + "group": "navigation" + } + ], + "editor/context": [ + { + "command": "devchat.askForCode", + "when": "editorTextFocus && editorHasSelection", + "group": "navigation", + "title": "add to DevChat" + }, + { + "command": "devchat.askForFile", + "when": "editorTextFocus && !editorHasSelection", + "group": "navigation", + "title": "add to DevChat" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run package", @@ -43,6 +78,7 @@ "@types/jest": "^29.5.1", "@types/mocha": "^10.0.1", "@types/node": "16.x", + "@types/shell-escape": "^0.2.1", "@types/vscode": "^1.77.0", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", @@ -63,6 +99,8 @@ "node-fetch": "^3.3.1", "nonce": "^1.0.4", "openai": "^3.2.1", + "quote": "^0.4.0", + "shell-escape": "^0.2.0", "uuid": "^9.0.0" } } diff --git a/src/__test__/devchat.test.ts b/src/__test__/devchat.test.ts deleted file mode 100644 index 92ebdc5..0000000 --- a/src/__test__/devchat.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import DevChat from "../devchat"; - -describe("DevChat", () => { - const devChat = new DevChat(); - - test("some test", () => { - const xx = 20; - expect(xx) - }) - - test("chat should return a valid response", async () => { - const chatResponse = await devChat.chat("Help me with TypeScript"); - - expect(chatResponse).toHaveProperty("prompt-hash"); - expect(chatResponse).toHaveProperty("user"); - expect(chatResponse).toHaveProperty("date"); - expect(chatResponse).toHaveProperty("response"); - expect(chatResponse).toHaveProperty("isError"); - - expect(chatResponse.isError).toBe(false); - expect(chatResponse.response.length).toBeGreaterThan(0); - }); - - test("log should return an array of log entries", async () => { - const logEntries = await devChat.log({ maxCount: 5 }); - - expect(Array.isArray(logEntries)).toBe(true); - expect(logEntries.length).toBeLessThanOrEqual(5); - - logEntries.forEach((entry) => { - expect(entry).toHaveProperty("prompt-hash"); - expect(entry).toHaveProperty("user"); - expect(entry).toHaveProperty("date"); - expect(entry).toHaveProperty("message"); - expect(entry).toHaveProperty("response"); - }); - }); -}); diff --git a/src/applyCode.ts b/src/applyCode.ts new file mode 100644 index 0000000..f227b40 --- /dev/null +++ b/src/applyCode.ts @@ -0,0 +1,46 @@ +const vscode = require('vscode'); + + +export async function applyCodeFile(text: string) { + if (vscode.window.visibleTextEditors.length > 1) { + vscode.window.showErrorMessage(`There are more then one visible text editors. Please close all but one and try again.`); + return; + } + + const editor = vscode.window.visibleTextEditors[0]; + if (!editor) { + return; + } + + const document = editor.document; + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + + await editor.edit((editBuilder: string) => { + editBuilder.replace(fullRange, text); + }); + } + +async function applyCode(text: string) { + if (vscode.window.visibleTextEditors.length > 1) { + vscode.window.showErrorMessage(`There are more then one visible text editors. Please close all but one and try again.`); + return; + } + + const editor = vscode.window.visibleTextEditors[0]; + if (!editor) { + return; + } + + const selection = editor.selection; + const start = selection.start; + const end = selection.end; + + await editor.edit((editBuilder: string) => { + editBuilder.replace(new vscode.Range(start, end), text); + }); + } + + export default applyCode; \ No newline at end of file diff --git a/src/chatPanel.ts b/src/chatPanel.ts index 399f865..28de3c8 100644 --- a/src/chatPanel.ts +++ b/src/chatPanel.ts @@ -9,7 +9,6 @@ import handleMessage from './messageHandler'; export default class ChatPanel { private static _instance: ChatPanel | undefined; private readonly _panel: vscode.WebviewPanel; - private _messageHistory: Array<{ role: string; content: string }>; private _disposables: vscode.Disposable[] = []; // Create or reveal the chat panel @@ -22,6 +21,10 @@ export default class ChatPanel { } } + public static currentPanel(): ChatPanel | undefined { + return ChatPanel._instance; + } + // Create a new webview panel private static createWebviewPanel(extensionUri: vscode.Uri): vscode.WebviewPanel { const column = vscode.window.activeTextEditor @@ -35,13 +38,13 @@ export default class ChatPanel { { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'media')], + retainContextWhenHidden: true } ); } private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; - this._messageHistory = []; this.setWebviewOptions(extensionUri); this.setWebviewContent(extensionUri); @@ -61,6 +64,10 @@ export default class ChatPanel { this._panel.webview.html = this._getHtmlContent(extensionUri); } + public panel() : vscode.WebviewPanel { + return this._panel; + } + // Register event listeners for the panel and webview private registerEventListeners() { this._panel.onDidDispose(() => this.dispose(), null, this._disposables); diff --git a/src/devchat.ts b/src/devchat.ts index 7099a4f..e739b41 100644 --- a/src/devchat.ts +++ b/src/devchat.ts @@ -1,21 +1,53 @@ -import { exec } from "child_process"; +// devchat.ts + +import { spawn } from "child_process"; import { promisify } from "util"; +import * as vscode from 'vscode'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; -const execAsync = promisify(exec); +const spawnAsync = async (command: string, args: string[], options: any, onData: (data: string) => void): Promise<{code: number, stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + let stdout = ''; + let stderr = ''; -interface ChatOptions { + child.stdout.on('data', (data) => { + const dataStr = data.toString(); + onData(dataStr); + stdout += dataStr; + }); + + child.stderr.on('data', (data) => { + stderr += data; + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({code, stdout, stderr }); + } else { + reject({code, stdout, stderr }); + } + }); + }); +}; + +const envPath = path.join(__dirname, '..', '.env'); +dotenv.config({ path: envPath }); + +export interface ChatOptions { parent?: string; reference?: string[]; header?: string[]; context?: string[]; } -interface LogOptions { +export interface LogOptions { skip?: number; maxCount?: number; } -interface LogEntry { +export interface LogEntry { "prompt-hash": string; user: string; date: string; @@ -23,7 +55,7 @@ interface LogEntry { response: string; } -interface ChatResponse { +export interface ChatResponse { "prompt-hash": string; user: string; date: string; @@ -32,25 +64,47 @@ interface ChatResponse { } class DevChat { - async chat(content: string, options: ChatOptions = {}): Promise { - let args = ""; - - if (options.parent) { - args += ` -p ${options.parent}`; - } - if (options.reference) { - args += ` -r ${options.reference.join(",")}`; - } - if (options.header) { - args += ` -h ${options.header.join(",")}`; - } - if (options.context) { - args += ` -c ${options.context.join(",")}`; - } - - const { stdout } = await execAsync(`devchat ${args} "${content}"`, { + async chat(content: string, options: ChatOptions = {}, onData: (data: string) => void): Promise { + let args = ["prompt"]; + + if (options.parent) { + args.push("-p", options.parent); + } + if (options.reference) { + args.push("-r", options.reference.join(",")); + } + if (options.header) { + args.push("--header", options.header.join(",")); + } + if (options.context) { + args.push("--context", options.context.join(",")); + } + args.push(content) + + const workspaceDir = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + const openaiApiKey = process.env.OPENAI_API_KEY; + + try { + const {code, stdout, stderr } = await spawnAsync('devchat', args, { maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB - }); + cwd: workspaceDir, + env: { + ...process.env, + OPENAI_API_KEY: openaiApiKey, + }, + }, onData); + + if (stderr) { + const errorMessage = stderr.trim().match(/Error:(.+)/)?.[1]; + return { + "prompt-hash": "", + user: "", + date: "", + response: errorMessage ? `Error: ${errorMessage}` : "Unknown error", + isError: true, + }; + } + const responseLines = stdout.trim().split("\n"); console.log(responseLines) @@ -63,8 +117,17 @@ class DevChat { isError: true, }; } - - if (responseLines[0].startsWith("error")) { + + let promptHashLine = ""; + for (let i = responseLines.length - 1; i >= 0; i--) { + if (responseLines[i].startsWith("prompt")) { + promptHashLine = responseLines[i]; + responseLines.splice(i, 1); + break; + } + } + + if (!promptHashLine) { return { "prompt-hash": "", user: "", @@ -73,18 +136,17 @@ class DevChat { isError: true, }; } - - const promptHashLine = responseLines.shift()!; + const promptHash = promptHashLine.split(" ")[1]; - + const userLine = responseLines.shift()!; const user = (userLine.match(/User: (.+)/)?.[1]) ?? ""; - + const dateLine = responseLines.shift()!; const date = (dateLine.match(/Date: (.+)/)?.[1]) ?? ""; - + const response = responseLines.join("\n"); - + return { "prompt-hash": promptHash, user, @@ -92,22 +154,50 @@ class DevChat { response, isError: false, }; + } catch (error: any) { + return { + "prompt-hash": "", + user: "", + date: "", + response: `Error: ${error.stderr}\nExit code: ${error.code}`, + isError: true, + }; + } } async log(options: LogOptions = {}): Promise { - let args = "log"; + let args = ["log"]; if (options.skip) { - args += ` --skip ${options.skip}`; + args.push('--skip', `${options.skip}`); } if (options.maxCount) { - args += ` --max-count ${options.maxCount}`; + args.push('--max-count', `${options.maxCount}`); } - const { stdout } = await execAsync(`devchat ${args}`, { - maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB - }); - return JSON.parse(stdout.trim()); + const workspaceDir = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + const openaiApiKey = process.env.OPENAI_API_KEY; + + try { + const {code, stdout, stderr } = await spawnAsync('devchat', args, { + maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB + cwd: workspaceDir, + env: { + ...process.env, + OPENAI_API_KEY: openaiApiKey, + }, + }, (partialResponse: string) => {}); + + if (stderr) { + console.error(stderr); + return []; + } + + return JSON.parse(stdout.trim()); + } catch (error) { + console.error(error) + return []; + } } } diff --git a/src/dtm.ts b/src/dtm.ts new file mode 100644 index 0000000..d4ff0af --- /dev/null +++ b/src/dtm.ts @@ -0,0 +1,41 @@ +// dtm.ts + +import { exec } from "child_process"; +import { promisify } from "util"; +import * as vscode from 'vscode'; + +const execAsync = promisify(exec); + +interface DtmResponse { + status: number; + message: string; + log: string; +} + +class DtmWrapper { + private workspaceDir: string; + + constructor() { + this.workspaceDir = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '.'; + } + + async scaffold(directoryTree: string): Promise { + const { stdout } = await execAsync(`dtm scaffold "${directoryTree}" -o json`, { + cwd: this.workspaceDir, + }); + return JSON.parse(stdout.trim()); + } + + async patch(patchFilePath: string): Promise { + try { + const { stdout } = await execAsync(`dtm patch ${patchFilePath} -o json`, { + cwd: this.workspaceDir, + }); + return JSON.parse(stdout.trim()); + } catch (e) { + return JSON.parse((e as Error & { stdout: string }).stdout.trim()); + } + } +} + +export default DtmWrapper; diff --git a/src/extension.ts b/src/extension.ts index e1c6773..4a2732b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,9 @@ const vscode = require('vscode'); const ChatPanel = require('./chatPanel').default; +const sendFileSelectMessage = require('./messageHandler').sendFileSelectMessage; +const sendCodeSelectMessage = require('./messageHandler').sendCodeSelectMessage; +const askAI = require('./messageHandler').askAI; + function activate(context: { extensionUri: any; subscriptions: any[]; }) { let disposable = vscode.commands.registerCommand('devchat.openChatPanel', () => { @@ -10,6 +14,56 @@ function activate(context: { extensionUri: any; subscriptions: any[]; }) { } }); + const disposable_add_context = vscode.commands.registerCommand('devchat.addConext', (uri: { path: any; }) => { + if (!ChatPanel.currentPanel()) { + if (vscode.workspace.workspaceFolders) { + ChatPanel.createOrShow(context.extensionUri); + } else { + vscode.window.showErrorMessage('Please open a directory before using the chat panel.'); + return + } + } + + sendFileSelectMessage(ChatPanel.currentPanel().panel(), uri.path); + }); + + const disposableCodeContext = vscode.commands.registerCommand('devchat.askForCode', async () => { + const editor = vscode.window.activeTextEditor; + if (editor) { + if (!ChatPanel.currentPanel()) { + if (vscode.workspace.workspaceFolders) { + ChatPanel.createOrShow(context.extensionUri); + } else { + vscode.window.showErrorMessage('Please open a directory before using the chat panel.'); + return + } + } + + const selectedText = editor.document.getText(editor.selection); + sendCodeSelectMessage(ChatPanel.currentPanel().panel(), selectedText); + } + }); + + const disposableAskFile = vscode.commands.registerCommand('devchat.askForFile', async () => { + const editor = vscode.window.activeTextEditor; + if (editor) { + if (!ChatPanel.currentPanel()) { + if (vscode.workspace.workspaceFolders) { + ChatPanel.createOrShow(context.extensionUri); + } else { + vscode.window.showErrorMessage('Please open a directory before using the chat panel.'); + return + } + } + + const selectedText = editor.document.getText(); + sendCodeSelectMessage(ChatPanel.currentPanel().panel(), selectedText); + } + }); + context.subscriptions.push(disposable); + context.subscriptions.push(disposable_add_context); + context.subscriptions.push(disposableCodeContext) + context.subscriptions.push(disposableAskFile) } exports.activate = activate; diff --git a/src/messageHandler.ts b/src/messageHandler.ts index df4cdcd..cacfabc 100644 --- a/src/messageHandler.ts +++ b/src/messageHandler.ts @@ -1,19 +1,108 @@ // messageHandler.ts import * as vscode from 'vscode'; -import DevChat from './devchat'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import DevChat, { LogOptions } from './devchat'; +import DtmWrapper from './dtm'; +import applyCode, {applyCodeFile} from './applyCode'; +import * as vscode3 from 'vscode'; + +const writeFileAsync = promisify(fs.writeFile); +const unlinkAsync = promisify(fs.unlink); + +let lastPromptHash: string | undefined; + +async function saveTempPatchFile(content: string): Promise { + const tempPatchFilePath = path.join(vscode.workspace.workspaceFolders![0].uri.fsPath, '.temp_patch_file.patch'); + await writeFileAsync(tempPatchFilePath, content); + return tempPatchFilePath; +} + +async function deleteTempPatchFile(filePath: string): Promise { + await unlinkAsync(filePath); +} + +export function sendFileSelectMessage(panel: vscode.WebviewPanel, filePath: string): void { + panel.webview.postMessage({ command: 'file_select', filePath }); +} + +export function sendCodeSelectMessage(panel: vscode.WebviewPanel, codeBlock: string): void { + panel.webview.postMessage({ command: 'code_select', codeBlock }); +} + +export function askAI(panel: vscode.WebviewPanel, codeBlock: string, question: string): void { + panel.webview.postMessage({ command: 'ask_ai', codeBlock, question }); +} + +// Add this function to messageHandler.ts +function parseMessageForContext(message: string): { context: string[]; text: string } { + const contextRegex = /\[context\|(.*?)\]/g; + const contextPaths = []; + let match; + + while ((match = contextRegex.exec(message)) !== null) { + contextPaths.push(match[1]); + } + + const text = message.replace(contextRegex, '').trim(); + return { context: contextPaths, text }; +} async function handleMessage( message: any, panel: vscode.WebviewPanel ): Promise { + const devChat = new DevChat(); + const dtmWrapper = new DtmWrapper(); + switch (message.command) { case 'sendMessage': - const devChat = new DevChat(); - const chatResponse = await devChat.chat(message.text); + const parsedMessage = parseMessageForContext(message.text); + const chatOptions: any = lastPromptHash ? { parent: lastPromptHash } : {}; + + if (parsedMessage.context.length > 0) { + chatOptions.context = parsedMessage.context; + } + + let partialData = ""; + const onData = (partialResponse: string) => { + partialData += partialResponse; + panel.webview.postMessage({ command: 'receiveMessagePartial', text: partialData }); + }; + + const chatResponse = await devChat.chat(parsedMessage.text, chatOptions, onData); + lastPromptHash = chatResponse["prompt-hash"]; const response = chatResponse.response; panel.webview.postMessage({ command: 'receiveMessage', text: response }); return; + case 'historyMessages': + const logOptions: LogOptions = message.options || {}; + const logEntries = await devChat.log(logOptions); + panel.webview.postMessage({ command: 'loadHistoryMessages', entries: logEntries }); + return; + case 'block_apply': + const tempPatchFile = await saveTempPatchFile(message.content); + try { + const patchResult = await dtmWrapper.patch(tempPatchFile); + await deleteTempPatchFile(tempPatchFile); + if (patchResult.status === 0) { + vscode.window.showInformationMessage('Patch applied successfully.'); + } else { + vscode.window.showErrorMessage(`Error applying patch: ${patchResult.message} ${patchResult.log}`); + } + } catch (error) { + await deleteTempPatchFile(tempPatchFile); + vscode.window.showErrorMessage(`Error applying patch: ${error}`); + } + return; + case 'code_apply': + await applyCode(message.content); + return; + case 'code_file_apply': + await applyCodeFile(message.content); + return; } }