Merge branch 'main' into pr/w1gs/87

This commit is contained in:
Andrew 2024-10-17 17:18:12 -07:00
commit e2e6cb0327
11 changed files with 447 additions and 403 deletions

View file

@ -9,9 +9,6 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.1", "@anthropic-ai/sdk": "^0.27.1",
"diff-match-patch": "^1.0.5",
"diff": "^7.0.0",
"ollama": "^0.5.9",
"openai": "^4.57.0" "openai": "^4.57.0"
}, },
"devDependencies": { "devDependencies": {
@ -23,7 +20,7 @@
"@types/node": "^22.5.1", "@types/node": "^22.5.1",
"@types/react": "^18.3.4", "@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/vscode": "1.89.0", "@types/vscode": "1.92.0",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
"@vscode/test-cli": "^0.0.10", "@vscode/test-cli": "^0.0.10",
@ -42,7 +39,8 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.10",
"typescript": "5.5.4", "typescript": "5.5.4",
"typescript-eslint": "^8.3.0" "typescript-eslint": "^8.3.0",
"uuid": "^10.0.0"
}, },
"engines": { "engines": {
"vscode": "^1.89.0" "vscode": "^1.89.0"
@ -769,9 +767,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/vscode": { "node_modules/@types/vscode": {
"version": "1.89.0", "version": "1.92.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.89.0.tgz", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.92.0.tgz",
"integrity": "sha512-TMfGKLSVxfGfoO8JfIE/neZqv7QLwS4nwPwL/NwMvxtAY2230H2I4Z5xx6836pmJvMAzqooRQ4pmLm7RUicP3A==", "integrity": "sha512-DcZoCj17RXlzB4XJ7IfKdPTcTGDLYvTOcTNkvtjXWF+K2TlKzHHkBEXNWQRpBIXixNEUgx39cQeTFunY0E2msw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2153,20 +2151,6 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
},
"node_modules/diff-sequences": { "node_modules/diff-sequences": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -5573,15 +5557,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ollama": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.9.tgz",
"integrity": "sha512-F/KZuDRC+ZsVCuMvcOYuQ6zj42/idzCkkuknGyyGVmNStMZ/sU3jQpvhnl4SyC0+zBzLiKNZJnJeuPFuieWZvQ==",
"license": "MIT",
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -7720,6 +7695,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@ -7780,12 +7769,6 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View file

@ -363,7 +363,7 @@
"@types/node": "^22.5.1", "@types/node": "^22.5.1",
"@types/react": "^18.3.4", "@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/vscode": "1.89.0", "@types/vscode": "1.92.0",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
"@vscode/test-cli": "^0.0.10", "@vscode/test-cli": "^0.0.10",
@ -382,13 +382,11 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.10",
"typescript": "5.5.4", "typescript": "5.5.4",
"typescript-eslint": "^8.3.0" "typescript-eslint": "^8.3.0",
"uuid": "^10.0.0"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.1", "@anthropic-ai/sdk": "^0.27.1",
"diff-match-patch": "^1.0.5", "openai": "^4.57.0"
"ollama": "^0.5.9",
"openai": "^4.57.0",
"diff": "^7.0.0"
} }
} }

View file

@ -55,7 +55,7 @@ export class SidebarWebviewProvider implements vscode.WebviewViewProvider {
const openAICompatibleEndpoint: string | undefined = vscode.workspace.getConfiguration('void.openAICompatible').get('endpoint'); const openAICompatibleEndpoint: string | undefined = vscode.workspace.getConfiguration('void.openAICompatible').get('endpoint');
this._webviewDeps.push('void.openAICompatible.endpoint'); this._webviewDeps.push('void.openAICompatible.endpoint');
if (openAICompatibleEndpoint) if (openAICompatibleEndpoint)
allowed_urls.push(openAICompatibleEndpoint); allowed_urls.push(openAICompatibleEndpoint+'/chat/completions');
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/index.js')); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/index.js'));
const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/styles.css')); const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist/sidebar/styles.css'));

View file

@ -1,6 +1,6 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { DisplayChangesProvider } from './DisplayChangesProvider'; import { DisplayChangesProvider } from './DisplayChangesProvider';
import { BaseDiffArea, ChatThreads, WebviewMessage } from './shared_types'; import { BaseDiffArea, ChatThreads, MessageFromSidebar, MessageToSidebar } from './shared_types';
import { SidebarWebviewProvider } from './SidebarWebviewProvider'; import { SidebarWebviewProvider } from './SidebarWebviewProvider';
import { ApiConfig } from './common/sendLLMMessage'; import { ApiConfig } from './common/sendLLMMessage';
@ -79,7 +79,7 @@ export function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri; const filePath = editor.document.uri;
// send message to the webview (Sidebar.tsx) // send message to the webview (Sidebar.tsx)
webviewProvider.webview.then(webview => webview.postMessage({ type: 'ctrl+l', selection: { selectionStr, selectionRange, filePath } } satisfies WebviewMessage)); webviewProvider.webview.then(webview => webview.postMessage({ type: 'ctrl+l', selection: { selectionStr, selectionRange, filePath } } satisfies MessageToSidebar));
}) })
); );
@ -105,23 +105,23 @@ export function activate(context: vscode.ExtensionContext) {
// top navigation bar commands // top navigation bar commands
context.subscriptions.push(vscode.commands.registerCommand('void.startNewThread', async () => { context.subscriptions.push(vscode.commands.registerCommand('void.startNewThread', async () => {
webview.postMessage({ type: 'startNewThread' } satisfies WebviewMessage) webview.postMessage({ type: 'startNewThread' } satisfies MessageToSidebar)
})) }))
context.subscriptions.push(vscode.commands.registerCommand('void.toggleThreadSelector', async () => { context.subscriptions.push(vscode.commands.registerCommand('void.toggleThreadSelector', async () => {
webview.postMessage({ type: 'toggleThreadSelector' } satisfies WebviewMessage) webview.postMessage({ type: 'toggleThreadSelector' } satisfies MessageToSidebar)
})) }))
// when config changes, send it to the sidebar // when config changes, send it to the sidebar
vscode.workspace.onDidChangeConfiguration(e => { vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('void')) { if (e.affectsConfiguration('void')) {
const apiConfig = getApiConfig() const apiConfig = getApiConfig()
webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage) webview.postMessage({ type: 'apiConfig', apiConfig } satisfies MessageToSidebar)
} }
}) })
// Receive messages in the extension from the sidebar webview (messages are sent using `postMessage`) // Receive messages in the extension from the sidebar webview (messages are sent using `postMessage`)
webview.onDidReceiveMessage(async (m: WebviewMessage) => { webview.onDidReceiveMessage(async (m: MessageFromSidebar) => {
if (m.type === 'requestFiles') { if (m.type === 'requestFiles') {
@ -131,7 +131,7 @@ export function activate(context: vscode.ExtensionContext) {
) )
// send contents to webview // send contents to webview
webview.postMessage({ type: 'files', files, } satisfies WebviewMessage) webview.postMessage({ type: 'files', files, } satisfies MessageToSidebar)
} else if (m.type === 'applyChanges') { } else if (m.type === 'applyChanges') {
@ -168,11 +168,11 @@ export function activate(context: vscode.ExtensionContext) {
} }
else if (m.type === 'getApiConfig') { else if (m.type === 'getApiConfig') {
const apiConfig = getApiConfig() const apiConfig = getApiConfig()
webview.postMessage({ type: 'apiConfig', apiConfig } satisfies WebviewMessage) webview.postMessage({ type: 'apiConfig', apiConfig } satisfies MessageToSidebar)
} }
else if (m.type === 'getAllThreads') { else if (m.type === 'getAllThreads') {
const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {} const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {}
webview.postMessage({ type: 'allThreads', threads } satisfies WebviewMessage) webview.postMessage({ type: 'allThreads', threads } satisfies MessageToSidebar)
} }
else if (m.type === 'persistThread') { else if (m.type === 'persistThread') {
const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {} const threads: ChatThreads = context.workspaceState.get('allThreads') ?? {}
@ -180,7 +180,7 @@ export function activate(context: vscode.ExtensionContext) {
context.workspaceState.update('allThreads', updatedThreads) context.workspaceState.update('allThreads', updatedThreads)
} }
else { else {
console.error('unrecognized command', m.type, m) console.error('unrecognized command', m)
} }
}) })
} }

View file

@ -38,45 +38,25 @@ type Diff = {
lenses: vscode.CodeLens[], lenses: vscode.CodeLens[],
} & BaseDiff } & BaseDiff
type WebviewMessage = ( // editor -> sidebar
type MessageToSidebar = (
// editor -> sidebar
| { type: 'ctrl+l', selection: CodeSelection } // user presses ctrl+l in the editor | { type: 'ctrl+l', selection: CodeSelection } // user presses ctrl+l in the editor
// sidebar -> editor
| { type: 'applyChanges', code: string } // user clicks "apply" in the sidebar
// sidebar -> editor
| { type: 'requestFiles', filepaths: vscode.Uri[] }
// editor -> sidebar
| { type: 'files', files: { filepath: vscode.Uri, content: string }[] } | { type: 'files', files: { filepath: vscode.Uri, content: string }[] }
// sidebar -> editor
| { type: 'getApiConfig' }
// editor -> sidebar
| { type: 'apiConfig', apiConfig: ApiConfig } | { type: 'apiConfig', apiConfig: ApiConfig }
// sidebar -> editor
| { type: 'getAllThreads' }
// editor -> sidebar
| { type: 'allThreads', threads: ChatThreads } | { type: 'allThreads', threads: ChatThreads }
// sidebar -> editor
| { type: 'persistThread', thread: ChatThreads[string] }
// editor -> sidebar
| { type: 'startNewThread' } | { type: 'startNewThread' }
// editor -> sidebar
| { type: 'toggleThreadSelector' } | { type: 'toggleThreadSelector' }
) )
// sidebar -> editor
type MessageFromSidebar = (
| { type: 'applyChanges', code: string } // user clicks "apply" in the sidebar
| { type: 'requestFiles', filepaths: vscode.Uri[] }
| { type: 'getApiConfig' }
| { type: 'getAllThreads' }
| { type: 'persistThread', thread: ChatThreads[string] }
)
type Command = WebviewMessage['type']
type ChatThreads = { type ChatThreads = {
[id: string]: { [id: string]: {
@ -105,8 +85,8 @@ export {
Diff, DiffArea, Diff, DiffArea,
CodeSelection, CodeSelection,
File, File,
WebviewMessage, MessageFromSidebar,
Command, MessageToSidebar,
ChatThreads, ChatThreads,
ChatMessage, ChatMessage,
} }

View file

@ -1,330 +1,44 @@
import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react" import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react"
import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage" import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage"
import { File, CodeSelection, WebviewMessage, ChatMessage } from "../shared_types" import { CodeSelection, ChatMessage, MessageToSidebar } from "../shared_types"
import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi" import { awaitVSCodeResponse, getVSCodeAPI, onMessageFromVSCode } from "./getVscodeApi"
import { marked } from 'marked'; import { SidebarThreadSelector } from "./SidebarThreadSelector";
import MarkdownRender from "./markdown/MarkdownRender";
import BlockCode from "./markdown/BlockCode";
import * as vscode from 'vscode'
import { SelectedFiles } from "./components/SelectedFiles";
import { useThreads } from "./threadsContext"; import { useThreads } from "./threadsContext";
import { SidebarChat } from "./SidebarChat";
const filesStr = (fullFiles: File[]) => {
return fullFiles.map(({ filepath, content }) =>
`
${filepath.fsPath}
\`\`\`
${content}
\`\`\``).join('\n')
}
const userInstructionsStr = (instructions: string, files: File[], selection: CodeSelection | null) => {
return `
${filesStr(files)}
${!selection ? '' : `
I am currently selecting this code:
\`\`\`${selection.selectionStr}\`\`\`
`}
Please edit the code following these instructions (or, if appropriate, answer my question instead):
${instructions}
If you make a change, rewrite the entire file.
`; // TODO don't rewrite the whole file on prompt, instead rewrite it when click Apply
}
const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => {
const role = chatMessage.role
const children = chatMessage.displayContent
if (!children)
return null
let chatbubbleContents: React.ReactNode
if (role === 'user') {
chatbubbleContents = <>
<SelectedFiles files={chatMessage.files} setFiles={null} />
{chatMessage.selection?.selectionStr && <BlockCode text={chatMessage.selection.selectionStr} hideToolbar />}
{children}
</>
}
else if (role === 'assistant') {
chatbubbleContents = <MarkdownRender string={children} /> // sectionsHTML
}
return <div className={`${role === 'user' ? 'text-right' : 'text-left'}`}>
<div className={`inline-block p-2 rounded-lg space-y-2 ${role === 'user' ? 'bg-vscode-input-bg text-vscode-input-fg' : ''} max-w-full`}>
{chatbubbleContents}
</div>
</div>
}
const ThreadSelector = ({ onClose }: { onClose: () => void }) => {
const { allThreads, currentThread, switchToThread } = useThreads()
return (
<div className="flex flex-col space-y-1">
<div className="text-right">
<button className="btn btn-sm" onClick={onClose}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* iterate through all past threads */}
{Object.keys(allThreads ?? {}).map((threadId) => {
const pastThread = (allThreads ?? {})[threadId];
return (
<button
key={pastThread.id}
className={`btn btn-sm btn-secondary ${pastThread.id === currentThread?.id ? "btn-primary" : ""}`}
onClick={() => switchToThread(pastThread.id)}
>
{new Date(pastThread.createdAt).toLocaleString()}
</button>
)
})}
</div>
)
}
const Sidebar = () => { const Sidebar = () => {
const { allThreads, currentThread, addMessageToHistory, startNewThread, } = useThreads()
// state of current message
const [selection, setSelection] = useState<CodeSelection | null>(null) // the code the user is selecting
const [files, setFiles] = useState<vscode.Uri[]>([]) // the names of the files in the chat
const [instructions, setInstructions] = useState('') // the user's instructions
// state of chat
const [messageStream, setMessageStream] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isThreadSelectorOpen, setIsThreadSelectorOpen] = useState(false) const [isThreadSelectorOpen, setIsThreadSelectorOpen] = useState(false)
const [requestFailed, setRequestFailed] = useState(false) const [requestFailed, setRequestFailed] = useState(false)
const [requestFailedReason, setRequestFailedReason] = useState('') const [requestFailedReason, setRequestFailedReason] = useState('')
const abortFnRef = useRef<(() => void) | null>(null)
const [apiConfig, setApiConfig] = useState<ApiConfig | null>(null)
// get Api Config on mount // get Api Config on mount
useEffect(() => { useEffect(() => {
getVSCodeAPI().postMessage({ type: 'getApiConfig' }) getVSCodeAPI().postMessage({ type: 'getApiConfig' })
}, []) }, [])
// Receive messages from the extension // Receive messages from the VSCode extension
useEffect(() => { useEffect(() => {
const listener = (event: MessageEvent) => { const listener = (event: MessageEvent) => {
const m = event.data as MessageToSidebar;
const m = event.data as WebviewMessage; onMessageFromVSCode(m)
// resolve any awaiting promises
// eg. it will resolve the promise below for `await VSCodeResponse('files')`
resolveAwaitingVSCodeResponse(m)
// if user pressed ctrl+l, add their selection to the sidebar
if (m.type === 'ctrl+l') {
setSelection(m.selection)
const filepath = m.selection.filePath
// add current file to the context if it's not already in the files array
if (!files.find(f => f.fsPath === filepath.fsPath))
setFiles(files => [...files, filepath])
}
// when get apiConfig, set
else if (m.type === 'apiConfig') {
setApiConfig(m.apiConfig)
}
// if they pressed the + to add a new chat
else if (m.type === 'startNewThread') {
setIsThreadSelectorOpen(false)
if (currentThread?.messages.length !== 0)
startNewThread()
}
// if they opened thread selector
else if (m.type === 'toggleThreadSelector') {
setIsThreadSelectorOpen(v => !v)
}
} }
window.addEventListener('message', listener); window.addEventListener('message', listener);
return () => { window.removeEventListener('message', listener) } return () => { window.removeEventListener('message', listener) }
}, [files, selection, startNewThread, currentThread]) }, [])
const formRef = useRef<HTMLFormElement | null>(null)
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (isLoading) return
// Reset any error messages from previous submit
setRequestFailed(false)
setRequestFailedReason('')
setIsLoading(true)
setInstructions('');
formRef.current?.reset(); // reset the form's text
setSelection(null)
setFiles([])
// request file content from vscode and await response
getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files })
const relevantFiles = await awaitVSCodeResponse('files')
// add message to chat history
const content = userInstructionsStr(instructions, relevantFiles.files, selection)
// console.log('prompt:\n', content)
const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files }
addMessageToHistory(newHistoryElt)
// send message to LLM
let { abort } = sendLLMMessage({
messages: [...(currentThread?.messages ?? []).map(m => ({ role: m.role, content: m.content })), { role: 'user', content }],
onText: (newText, fullText) => setMessageStream(fullText),
onFinalMessage: (content) => {
// add assistant's message to chat history, and clear selection
const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, }
addMessageToHistory(newHistoryElt)
// clear selection
setMessageStream('')
setIsLoading(false)
},
onError: (message) => { onStop(); setRequestFailed(true); setRequestFailedReason(message)},
apiConfig: apiConfig
})
abortFnRef.current = abort
}
const onStop = useCallback(() => {
// abort claude
abortFnRef.current?.()
// if messageStream was not empty, add it to the history
const llmContent = messageStream || '(canceled)'
const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent }
addMessageToHistory(newHistoryElt)
setMessageStream('')
setIsLoading(false)
}, [addMessageToHistory, messageStream])
//Clear code selection
const clearSelection = () => {
setSelection(null);
};
return <> return <>
<div className="flex flex-col h-screen w-full"> <div className="flex flex-col h-screen w-full">
{isThreadSelectorOpen && ( {isThreadSelectorOpen && (
<div className="mb-2 max-h-[30vh] overflow-y-auto"> <div className="mb-2 max-h-[30vh] overflow-y-auto">
<ThreadSelector onClose={() => setIsThreadSelectorOpen(false)} /> <SidebarThreadSelector onClose={() => setIsThreadSelectorOpen(false)} />
</div> </div>
)} )}
<div className="overflow-y-auto overflow-x-hidden space-y-4">
{/* previous messages */}
{currentThread !== null && currentThread.messages.map((message, i) =>
<ChatBubble key={i} chatMessage={message} />
)}
{/* message stream */}
<ChatBubble chatMessage={{ role: 'assistant', content: messageStream, displayContent: messageStream }} />
</div>
{/* chatbar */}
<div className="shrink-0 py-4">
{/* selection */}
<div className="text-left">
<div className="relative"> <SidebarChat setIsThreadSelectorOpen={setIsThreadSelectorOpen} />
<div className="input">
{/* selection */}
{(files.length || selection?.selectionStr) && <div className="p-2 pb-0 space-y-2">
{/* selected files */}
<SelectedFiles files={files} setFiles={setFiles} />
{/* selected code */}
{!!selection?.selectionStr && (
<BlockCode className="rounded bg-vscode-sidebar-bg" text={selection.selectionStr} toolbar={(
<button
onClick={clearSelection}
className="btn btn-secondary btn-sm border border-vscode-input-border rounded"
>
Remove
</button>
)} />
)}
</div>}
{/* error message */}
{requestFailed && (
<div className="bg-gray-800 text-red-500 text-center p-4 mb-4 rounded-md shadow-md">
<div className="text-lg">{`${requestFailedReason}`}</div>
</div>
)}
<form
ref={formRef}
className="flex flex-row items-center rounded-md p-2"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }}
onSubmit={(e) => {
console.log('submit!')
e.preventDefault();
onSubmit(e)
}}>
{/* input */}
<textarea
onChange={(e) => { setInstructions(e.target.value) }}
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
placeholder="Ctrl+L to select"
rows={1}
onInput={e => { e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px' }} // Adjust height dynamically
/>
{/* submit button */}
{isLoading ?
<button
onClick={onStop}
className="btn btn-primary rounded-r-lg max-h-10 p-2"
type='button'
>Stop</button>
: <button
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
disabled={!instructions}
type='submit'
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
}
</form>
</div>
</div>
</div>
</div>
</div> </div>
</> </>

View file

@ -0,0 +1,280 @@
import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { marked } from 'marked';
import MarkdownRender from "./markdown/MarkdownRender";
import BlockCode from "./markdown/BlockCode";
import { SelectedFiles } from "./components/SelectedFiles";
import { File, ChatMessage, CodeSelection } from "../shared_types";
import * as vscode from 'vscode'
import { awaitVSCodeResponse, getVSCodeAPI, onMessageFromVSCode, useOnVSCodeMessage } from "./getVscodeApi";
import { useThreads } from "./threadsContext";
import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage";
const filesStr = (fullFiles: File[]) => {
return fullFiles.map(({ filepath, content }) =>
`
${filepath.fsPath}
\`\`\`
${content}
\`\`\``).join('\n')
}
const userInstructionsStr = (instructions: string, files: File[], selection: CodeSelection | null) => {
let str = '';
if (files.length > 0) {
str += filesStr(files);
}
if (selection) {
str += `
I am currently selecting this code:
\t\`\`\`${selection.selectionStr}\`\`\`
`;
}
if (files.length > 0 && selection) {
str += `
Please edit the selected code or the entire file following these instructions:
`;
} else if (files.length > 0) {
str += `
Please edit the file following these instructions:
`;
} else if (selection) {
str += `
Please edit the selected code following these instructions:
`;
}
str += `
\t${instructions}
`;
if (files.length > 0) {
str += `
\tIf you make a change, rewrite the entire file.
`; // TODO don't rewrite the whole file on prompt, instead rewrite it when click Apply
}
return str;
};
const ChatBubble = ({ chatMessage }: { chatMessage: ChatMessage }) => {
const role = chatMessage.role
const children = chatMessage.displayContent
if (!children)
return null
let chatbubbleContents: React.ReactNode
if (role === 'user') {
chatbubbleContents = <>
<SelectedFiles files={chatMessage.files} setFiles={null} />
{chatMessage.selection?.selectionStr && <BlockCode text={chatMessage.selection.selectionStr} hideToolbar />}
{children}
</>
}
else if (role === 'assistant') {
chatbubbleContents = <MarkdownRender string={children} /> // sectionsHTML
}
return <div className={`${role === 'user' ? 'text-right' : 'text-left'}`}>
<div className={`inline-block p-2 rounded-lg space-y-2 ${role === 'user' ? 'bg-vscode-input-bg text-vscode-input-fg' : ''} max-w-full`}>
{chatbubbleContents}
</div>
</div>
}
export const SidebarChat = ({ setIsThreadSelectorOpen }: { setIsThreadSelectorOpen: (v: boolean | ((v: boolean) => boolean)) => void }) => {
// state of current message
const [selection, setSelection] = useState<CodeSelection | null>(null) // the code the user is selecting
const [files, setFiles] = useState<vscode.Uri[]>([]) // the names of the files in the chat
const [instructions, setInstructions] = useState('') // the user's instructions
// state of chat
const [messageStream, setMessageStream] = useState('')
const [isLoading, setIsLoading] = useState(false)
const abortFnRef = useRef<(() => void) | null>(null)
// higher level state
const { allThreads, currentThread, addMessageToHistory, startNewThread, } = useThreads()
const [apiConfig, setApiConfig] = useState<ApiConfig | null>(null)
// if user pressed ctrl+l, add their selection to the sidebar
useOnVSCodeMessage('ctrl+l', (m) => {
setSelection(m.selection)
const filepath = m.selection.filePath
// add current file to the context if it's not already in the files array
if (!files.find(f => f.fsPath === filepath.fsPath))
setFiles(files => [...files, filepath])
})
// when get apiConfig, set
useOnVSCodeMessage('apiConfig', (m) => {
setApiConfig(m.apiConfig)
})
// if they pressed the + to add a new chat
useOnVSCodeMessage('startNewThread', (m) => {
setIsThreadSelectorOpen(false)
if (currentThread?.messages.length !== 0)
startNewThread()
})
// if they opened thread selector
useOnVSCodeMessage('toggleThreadSelector', (m) => {
setIsThreadSelectorOpen(v => !v)
})
const formRef = useRef<HTMLFormElement | null>(null)
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
setInstructions('');
formRef.current?.reset(); // reset the form's text
setSelection(null)
setFiles([])
// request file content from vscode and await response
getVSCodeAPI().postMessage({ type: 'requestFiles', filepaths: files })
const relevantFiles = await awaitVSCodeResponse('files')
// add message to chat history
const content = userInstructionsStr(instructions, relevantFiles.files, selection)
// console.log('prompt:\n', content)
const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files }
addMessageToHistory(newHistoryElt)
// send message to LLM
let { abort } = sendLLMMessage({
messages: [...(currentThread?.messages ?? []).map(m => ({ role: m.role, content: m.content })), { role: 'user', content }],
onText: (newText, fullText) => setMessageStream(fullText),
onFinalMessage: (content) => {
// add assistant's message to chat history, and clear selection
const newHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content, }
addMessageToHistory(newHistoryElt)
// clear selection
setMessageStream('')
setIsLoading(false)
},
apiConfig: apiConfig
})
abortFnRef.current = abort
}
const onStop = useCallback(() => {
// abort claude
abortFnRef.current?.()
// if messageStream was not empty, add it to the history
const llmContent = messageStream || '(canceled)'
const newHistoryElt: ChatMessage = { role: 'assistant', displayContent: messageStream, content: llmContent }
addMessageToHistory(newHistoryElt)
setMessageStream('')
setIsLoading(false)
}, [addMessageToHistory, messageStream])
//Clear code selection
const clearSelection = () => {
setSelection(null);
};
return <>
<div className="overflow-y-auto overflow-x-hidden space-y-4">
{/* previous messages */}
{currentThread !== null && currentThread.messages.map((message, i) =>
<ChatBubble key={i} chatMessage={message} />
)}
{/* message stream */}
<ChatBubble chatMessage={{ role: 'assistant', content: messageStream, displayContent: messageStream }} />
</div>
{/* chatbar */}
<div className="shrink-0 py-4">
{/* selection */}
<div className="text-left">
<div className="relative">
<div className="input">
{/* selection */}
{(files.length || selection?.selectionStr) && <div className="p-2 pb-0 space-y-2">
{/* selected files */}
<SelectedFiles files={files} setFiles={setFiles} />
{/* selected code */}
{!!selection?.selectionStr && (
<BlockCode className="rounded bg-vscode-sidebar-bg" text={selection.selectionStr} toolbar={(
<button
onClick={clearSelection}
className="btn btn-secondary btn-sm border border-vscode-input-border rounded"
>
Remove
</button>
)} />
)}
</div>}
<form
ref={formRef}
className="flex flex-row items-center rounded-md p-2"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) onSubmit(e) }}
onSubmit={(e) => {
console.log('submit!')
e.preventDefault();
onSubmit(e)
}}>
{/* input */}
<textarea
onChange={(e) => { setInstructions(e.target.value) }}
className="w-full p-2 leading-tight resize-none max-h-[50vh] overflow-hidden bg-transparent border-none !outline-none"
placeholder="Ctrl+L to select"
rows={1}
onInput={e => { e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px' }} // Adjust height dynamically
/>
{/* submit button */}
{isLoading ?
<button
onClick={onStop}
className="btn btn-primary rounded-r-lg max-h-10 p-2"
type='button'
>Stop</button>
: <button
className="btn btn-primary font-bold size-8 flex justify-center items-center rounded-full p-2 max-h-10"
disabled={!instructions}
type='submit'
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>
</button>
}
</form>
</div>
</div>
</div>
</div>
</>
}

View file

@ -0,0 +1,40 @@
import React from "react";
import { ThreadsProvider, useThreads } from "./threadsContext";
export const SidebarThreadSelector = ({ onClose }: { onClose: () => void }) => {
const { allThreads, currentThread, switchToThread } = useThreads()
return (
<div className="flex flex-col space-y-1">
<div className="text-right">
<button className="btn btn-sm" onClick={onClose}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="size-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* iterate through all past threads */}
{Object.keys(allThreads ?? {}).map((threadId) => {
const pastThread = (allThreads ?? {})[threadId];
return (
<button
key={pastThread.id}
className={`btn btn-sm btn-secondary ${pastThread.id === currentThread?.id ? "btn-primary" : ""}`}
onClick={() => switchToThread(pastThread.id)}
>
{new Date(pastThread.createdAt).toLocaleString()}
</button>
)
})}
</div>
)
}

View file

@ -1,48 +1,77 @@
import { Command, WebviewMessage } from "../shared_types"; import { useEffect } from "react";
import { MessageFromSidebar, MessageToSidebar, } from "../shared_types";
import { v4 as uuidv4 } from 'uuid';
type Command = MessageToSidebar['type']
// message -> res[] // messageType -> res[]
const awaiting: { [c in Command]: ((res: any) => void)[] } = { const onetimeCallbacks: { [C in Command]: ((res: any) => void)[] } = {
"ctrl+l": [], "ctrl+l": [],
"applyChanges": [],
"requestFiles": [],
"files": [], "files": [],
"apiConfig": [], "apiConfig": [],
"getApiConfig": [],
"startNewThread": [], "startNewThread": [],
"getAllThreads": [],
"allThreads": [], "allThreads": [],
"persistThread": [],
"toggleThreadSelector": [] "toggleThreadSelector": []
} }
// messageType -> id -> res
const callbacks: { [C in Command]: { [id: string]: ((res: any) => void) } } = {
"ctrl+l": {},
"files": {},
"apiConfig": {},
"startNewThread": {},
"allThreads": {},
"toggleThreadSelector": {}
}
// use this function to await responses // use this function to await responses
export const awaitVSCodeResponse = <C extends Command>(c: C) => { export const awaitVSCodeResponse = <C extends Command>(c: C) => {
let result: Promise<WebviewMessage & { type: C }> = new Promise((res, rej) => { let result: Promise<MessageToSidebar & { type: C }> = new Promise((res, rej) => {
awaiting[c].push(res) onetimeCallbacks[c].push(res)
}) })
return result return result
} }
export const resolveAwaitingVSCodeResponse = (m: WebviewMessage) => {
// resolve all promises for this message // use this function to add a listener to a certain type of message
for (let res of awaiting[m.type]) { export const useOnVSCodeMessage = <C extends Command>(messageType: C, fn: (e: MessageToSidebar & { type: C }) => void) => {
useEffect(() => {
const mType = messageType
const callbackId: string = uuidv4();
// @ts-ignore
callbacks[mType][callbackId] = fn;
return () => { delete callbacks[mType][callbackId] }
}, [messageType, fn])
}
// this function gets called whenever sidebar receives a message - it should only mount once
export const onMessageFromVSCode = (m: MessageToSidebar) => {
// resolve all promises for this message type
for (let res of onetimeCallbacks[m.type]) {
res(m)
onetimeCallbacks[m.type].splice(0) // clear the array
}
// call the listener for this message type
for (let res of Object.values(callbacks[m.type])) {
res(m) res(m)
awaiting[m.type].splice(0) // clear the array
} }
} }
// VS Code exposes the function acquireVsCodeApi() to us, it should only get called once
let vsCodeApi: ReturnType<AcquireVsCodeApiType> | undefined;
type AcquireVsCodeApiType = () => { type AcquireVsCodeApiType = () => {
postMessage(message: WebviewMessage): void; postMessage(message: MessageFromSidebar): void;
// setState(state: any): void; // getState and setState are made obsolete by us using { retainContextWhenHidden: true } // setState(state: any): void; // getState and setState are made obsolete by us using { retainContextWhenHidden: true }
// getState(): any; // getState(): any;
}; };
// VS Code exposes the function acquireVsCodeApi() to us, this variable makes sure it only gets called once
let vsCodeApi: ReturnType<AcquireVsCodeApiType> | undefined;
export function getVSCodeAPI(): ReturnType<AcquireVsCodeApiType> { export function getVSCodeAPI(): ReturnType<AcquireVsCodeApiType> {
if (vsCodeApi) if (vsCodeApi)
return vsCodeApi; return vsCodeApi;

View file

@ -1,6 +1,9 @@
import React, { ReactNode, useCallback, useEffect, useState } from "react" import React, { ReactNode, useCallback, useEffect, useState } from "react"
import { getVSCodeAPI } from "../getVscodeApi" import { getVSCodeAPI } from "../getVscodeApi"
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDarkReasonable } from "react-syntax-highlighter/dist/esm/styles/hljs";
enum CopyButtonState { enum CopyButtonState {
Copy = "Copy", Copy = "Copy",
Copied = "Copied!", Copied = "Copied!",
@ -12,17 +15,27 @@ const COPY_FEEDBACK_TIMEOUT = 1000
// code block with toolbar (Apply, Copy, etc) at top // code block with toolbar (Apply, Copy, etc) at top
const BlockCode = ({ const BlockCode = ({
text, text,
language,
toolbar, toolbar,
hideToolbar = false, hideToolbar = false,
className, className,
}: { }: {
text: string text: string
language?: string
toolbar?: ReactNode toolbar?: ReactNode
hideToolbar?: boolean hideToolbar?: boolean
className?: string className?: string
}) => { }) => {
const [copyButtonState, setCopyButtonState] = useState(CopyButtonState.Copy) const [copyButtonState, setCopyButtonState] = useState(CopyButtonState.Copy)
const customStyle = {
...atomOneDarkReasonable,
'code[class*="language-"]': {
...atomOneDarkReasonable['code[class*="language-"]'],
background: "none",
},
}
useEffect(() => { useEffect(() => {
if (copyButtonState !== CopyButtonState.Copy) { if (copyButtonState !== CopyButtonState.Copy) {
setTimeout(() => { setTimeout(() => {
@ -71,7 +84,14 @@ const BlockCode = ({
<div <div
className={`overflow-x-auto rounded-sm text-vscode-editor-fg bg-vscode-editor-bg ${!hideToolbar ? "rounded-tl-none" : ""} ${className}`} className={`overflow-x-auto rounded-sm text-vscode-editor-fg bg-vscode-editor-bg ${!hideToolbar ? "rounded-tl-none" : ""} ${className}`}
> >
<pre className="p-2">{text}</pre> <SyntaxHighlighter
language={language}
style={customStyle}
className={"rounded-sm"}
>
{text}
</SyntaxHighlighter>
</div> </div>
</div> </div>
) )

View file

@ -12,7 +12,7 @@ const RenderToken = ({ token, nested = false }: { token: Token | string, nested?
} }
if (t.type === "code") { if (t.type === "code") {
return <BlockCode text={t.text} /> return <BlockCode text={t.text} language={t.lang} />
} }
if (t.type === "heading") { if (t.type === "heading") {