changeDiff prompt, chat thread state, improve import/export

This commit is contained in:
Andrew Pareles 2025-04-20 18:32:13 -07:00
parent 39ea63b207
commit 58b4d90b49
7 changed files with 121 additions and 85 deletions

View file

@ -641,15 +641,16 @@ class ChatThreadService extends Disposable implements IChatThreadService {
resMessageIsDonePromise(toolCall) // resolve with tool calls
},
onError: (error) => {
onError: async (error) => {
const messageSoFar = this.streamState[threadId]?.displayContentSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
if (nAttempts < CHAT_RETRIES) {
nAttempts += 1
shouldRetry = true
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
timeout(RETRY_DELAY).then(() => { resMessageIsDonePromise() })
await timeout(RETRY_DELAY)
resMessageIsDonePromise()
}
else {
// const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar
@ -661,9 +662,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
},
onAbort: () => {
// stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it)
aborted = true
resMessageIsDonePromise()
this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode })
aborted = true
},
})
@ -678,12 +679,12 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const toolCall = await messageIsDonePromise // wait for message to complete
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
if (shouldRetry) {
continue
}
if (aborted) {
return
}
// this is a complete hack to make it so if an error loop was aborted, we stop (because onAbort does not get called if error happens instantly)
// maybe we should remove all the abort stuff and just make it so that we only go by state?
if (!this.streamState[threadId]?.isRunning) { return }
if (aborted) { return }
if (shouldRetry) { continue }
// call tool if there is one
const tool: RawToolCallObj | undefined = toolCall
@ -692,8 +693,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// stop if interrupted. we don't have to do this for llmMessage because we have a stream token for it and onAbort gets called, but we don't have the equivalent for tools.
// just detect tool interruption which is the same as chat interruption right now
if (interrupted) { return }
if (!this.streamState[threadId]?.isRunning) { return }
if (aborted) { return }
if (interrupted) { return }
if (awaitingUserApproval) {
console.log('awaiting...')
@ -1419,14 +1421,13 @@ We only need to do it for files that were edited since `from`, ie files between
openNewThread() {
// if a thread with 0 messages already exists, switch to it
const { allThreads: currentThreads } = this.state
for (const threadId in currentThreads) {
if (currentThreads[threadId]!.messages.length === 0) {
// switch to the thread
this.switchToThread(threadId)
}
}
for (const threadId in currentThreads) {
if (currentThreads[threadId]!.messages.length === 0) {
// switch to the existing empty thread and exit
this.switchToThread(threadId)
return
}
}
// otherwise, start a new thread
const newThread = newThreadObject()

View file

@ -1447,10 +1447,10 @@ export const ListableToolItem = ({ name, onClick, isSmall, className, showDot }:
const EditToolChildren = ({ uri, changeDescription }: { uri: URI | undefined, changeDescription: string }) => {
const EditToolChildren = ({ uri, changeDiff }: { uri: URI | undefined, changeDiff: string }) => {
return <div className='!select-text cursor-auto'>
<SmallProseWrapper>
<ChatMarkdownRender string={changeDescription} codeURI={uri} chatMessageLocation={undefined} />
<ChatMarkdownRender string={changeDiff} codeURI={uri} chatMessageLocation={undefined} />
</SmallProseWrapper>
</div>
}
@ -1980,7 +1980,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
componentParams.children = <ToolChildrenWrapper className='bg-void-bg-3'>
<EditToolChildren
uri={params.uri}
changeDescription={params.changeDescription}
changeDiff={params.changeDiff}
/>
</ToolChildrenWrapper>
componentParams.desc2 = <JumpToFileButton uri={params.uri} />
@ -1997,7 +1997,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
componentParams.desc2 = <EditToolHeaderButtons
applyBoxId={applyBoxId}
uri={params.uri}
codeStr={params.changeDescription}
codeStr={params.changeDiff}
/>
}
@ -2010,7 +2010,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
componentParams.children = <ToolChildrenWrapper className='bg-void-bg-3'>
<EditToolChildren
uri={params.uri}
changeDescription={params.changeDescription}
changeDiff={params.changeDiff}
/>
</ToolChildrenWrapper>
}
@ -2027,7 +2027,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
{/* content */}
<EditToolChildren
uri={params.uri}
changeDescription={params.changeDescription}
changeDiff={params.changeDiff}
/>
</ToolChildrenWrapper>
}
@ -2638,7 +2638,7 @@ const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) =>
>
<EditToolChildren
uri={uri}
changeDescription={toolCallSoFar.rawParams.change_description ?? ''}
changeDiff={toolCallSoFar.rawParams.change_diff ?? ''}
/>
<IconLoading />
</ToolHeaderWrapper>

View file

@ -295,7 +295,7 @@ const TrashButton = ({ threadId }: { threadId: string }) => {
onClick={() => { setIsTrashPressed(true); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Delete thread?'
data-tooltip-content='Delete thread'
/>
)
}
@ -386,7 +386,7 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
<span className="truncate overflow-hidden text-ellipsis">{firstMsg}</span>
</span>
<div className="flex items-center gap-2 opacity-60">
<div className="flex items-center gap-x-1 opacity-60">
{idx === hoveredIdx ?
<>
{/* trash icon */}

View file

@ -153,6 +153,36 @@ const AddButton = ({ disabled, text = 'Add', ...props }: { disabled?: boolean, t
}
// ConfirmButton prompts for a second click to confirm an action, cancels if clicking outside
const ConfirmButton = ({ children, onConfirm, className }: { children: React.ReactNode, onConfirm: () => void, className?: string }) => {
const [confirm, setConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!confirm) return;
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setConfirm(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [confirm]);
return (
<div ref={ref} className={`inline-block`}>
<VoidButtonBgDarken className={className} onClick={() => {
if (!confirm) {
setConfirm(true);
} else {
onConfirm();
setConfirm(false);
}
}}>
{confirm ? `Confirm Reset` : children}
</VoidButtonBgDarken>
</div>
);
};
// shows a providerName dropdown if no `providerName` is given
export const AddModelInputBox = ({ providerName: permanentProviderName, className, compact }: { providerName?: ProviderName, className?: string, compact?: boolean }) => {
@ -907,33 +937,7 @@ export const Settings = () => {
{/* separator */}
<div className='w-full h-[1px] my-4' />
{/* Download & Upload Settings and Chats */}
<div className='flex gap-2 mb-6'>
<input key={2 * s} ref={fileInputSettingsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Settings')} />
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { fileInputSettingsRef.current?.click() }}>
Upload Settings
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => onDownload('Settings')}>
Download Settings
</VoidButtonBgDarken>
<input key={2 * s + 1} ref={fileInputChatsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Chats')} />
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { fileInputChatsRef.current?.click() }}>
Upload Chats
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => onDownload('Chats')}>
Download Chats
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { voidSettingsService.resetState() }}>
Reset Settings
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { chatThreadsService.resetState() }}>
Reset Chats
</VoidButtonBgDarken>
</div>
{/* Models section (formerly FeaturesTab) */}
{/* Models section (formerly FeaturesTab) */}
<ErrorBoundary>
@ -1112,10 +1116,10 @@ export const Settings = () => {
{/* General section (formerly GeneralTab) */}
<div className='mt-12'>
<ErrorBoundary>
<h2 className={`text-3xl mb-2 mt-12`}>One-Click Switch</h2>
<h4 className={`text-void-fg-3 mb-4`}>{`Transfer your settings from another editor to Void in one click.`}</h4>
<h2 className='text-3xl mb-2 mt-12'>One-Click Switch</h2>
<h4 className='text-void-fg-3 mb-4'>{`Transfer your settings from another editor to Void in one click.`}</h4>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<OneClickSwitchButton className='w-48' fromEditor="VS Code" />
<OneClickSwitchButton className='w-48' fromEditor="Cursor" />
<OneClickSwitchButton className='w-48' fromEditor="Windsurf" />
@ -1123,6 +1127,41 @@ export const Settings = () => {
</ErrorBoundary>
</div>
{/* Import/Export section, as its own block right after One-Click Switch */}
<div className='mt-12'>
<h2 className='text-3xl mb-2'>Import/Export</h2>
<div className='flex gap-8'>
{/* Settings Subcategory */}
<div className='flex flex-col gap-2 max-w-48 w-full'>
<h3 className='text-xl mb-2'>Settings</h3>
<input key={2 * s} ref={fileInputSettingsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Settings')} />
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => { fileInputSettingsRef.current?.click() }}>
Import Settings
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => onDownload('Settings')}>
Export Settings
</VoidButtonBgDarken>
<ConfirmButton className='px-4 py-1 w-full' onConfirm={() => { voidSettingsService.resetState(); }}>
Reset Settings
</ConfirmButton>
</div>
{/* Chats Subcategory */}
<div className='flex flex-col gap-2 w-full max-w-48'>
<h3 className='text-xl mb-2'>Chat</h3>
<input key={2 * s + 1} ref={fileInputChatsRef} type='file' accept='.json' className='hidden' onChange={handleUpload('Chats')} />
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => { fileInputChatsRef.current?.click() }}>
Import Chats
</VoidButtonBgDarken>
<VoidButtonBgDarken className='px-4 py-1 w-full' onClick={() => onDownload('Chats')}>
Export Chats
</VoidButtonBgDarken>
<ConfirmButton className='px-4 py-1 w-full' onConfirm={() => { chatThreadsService.resetState(); }}>
Reset Chats
</ConfirmButton>
</div>
</div>
</div>
<div className='mt-12'>
@ -1131,23 +1170,17 @@ export const Settings = () => {
<h4 className={`text-void-fg-3 mb-4`}>{`IDE settings, keyboard settings, and theme customization.`}</h4>
<ErrorBoundary>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.openSettings') }}>
<div className='flex flex-col gap-2 justify-center max-w-48 w-full'>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.openSettings') }}>
General Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.openGlobalKeybindings') }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.openGlobalKeybindings') }}>
Keyboard Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { commandService.executeCommand('workbench.action.selectTheme') }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { commandService.executeCommand('workbench.action.selectTheme') }}>
Theme Settings
</VoidButtonBgDarken>
</div>
<div className='my-4'>
<VoidButtonBgDarken className='px-4 py-2' onClick={() => { nativeHostService.showItemInFolder(environmentService.logsHome.fsPath) }}>
<VoidButtonBgDarken className='px-4 py-1' onClick={() => { nativeHostService.showItemInFolder(environmentService.logsHome.fsPath) }}>
Open Logs
</VoidButtonBgDarken>
</div>

View file

@ -242,10 +242,10 @@ export class ToolsService implements IToolsService {
},
edit_file: (params: RawToolParamsObj) => {
const { uri: uriStr, change_description: changeDescriptionUnknown } = params
const { uri: uriStr, change_diff: changeDiffUnknown } = params
const uri = validateURI(uriStr)
const changeDescription = validateStr('changeDescription', changeDescriptionUnknown)
return { uri, changeDescription }
const changeDiff = validateStr('changeDiff', changeDiffUnknown)
return { uri, changeDiff }
},
// ---
@ -383,14 +383,14 @@ export class ToolsService implements IToolsService {
return { result: {} }
},
edit_file: async ({ uri, changeDescription }) => {
edit_file: async ({ uri, changeDiff }) => {
await voidModelService.initializeModel(uri)
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and resume later.`)
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and ask the user to resume later.`)
}
const opts = {
uri,
applyStr: changeDescription,
applyStr: changeDiff,
from: 'ClickApply',
startBehavior: 'keep-conflicts',
} as const

View file

@ -204,12 +204,14 @@ export const voidTools = {
description: `Edits the contents of a file given the file's URI and a description.`,
params: {
...uriParam('file'),
change_description: {
change_diff: {
description: `\
Your description MUST be wrapped in triple backticks. \
A code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. \
NEVER re-write the whole file. Bias towards writing as little as possible. \
Here's an example of a good description:\n${editToolDescriptionExample}`
A code diff describing the change to make to the file. \
Your DIFF is the only context that will be given to another LLM to apply the change, so it must be accurate and complete. \
Your DIFF MUST be wrapped in triple backticks. \
NEVER re-write the whole file. Always bias towards writing as little as possible. \
Use comments like "// ... existing code ..." to condense your writing. \
Here's an example of a good output:\n${editToolDescriptionExample}`
}
},
},
@ -506,7 +508,7 @@ export const DIVIDER = `=======`
export const FINAL = `>>>>>>> UPDATED`
export const searchReplace_systemMessage = `\
You are a coding assistant that takes in a diff describing of a change to make, and outputs SEARCH/REPLACE code blocks which implement the change.
You are a coding assistant that takes in a diff, and outputs SEARCH/REPLACE code blocks to implement the change(s) in the diff.
The diff will be labeled \`DIFF\` and the original file will be labeled \`ORIGINAL_FILE\`.
Format your SEARCH/REPLACE blocks as follows:
@ -518,11 +520,11 @@ ${DIVIDER}
${FINAL}
${tripleTick[1]}
1. Your SEARCH/REPLACE block(s) must implement the change EXACTLY.
1. Your SEARCH/REPLACE block(s) must implement the diff EXACTLY. Do NOT leave anything out.
2. Assume any comments in the diff are PART OF THE CHANGE. Include them in the output.
2. You are allowed to output multiple SEARCH/REPLACE blocks to implement the change.
3. You are allowed to output multiple SEARCH/REPLACE blocks.
3. Assume any comments in the diff are PART OF THE CHANGE. Include them in the output.
4. Your output should consist ONLY of SEARCH/REPLACE blocks. Do NOT output any text or explanations before or after this.

View file

@ -42,7 +42,7 @@ export type ToolCallParams = {
'search_in_file': { uri: URI, query: string, isRegex: boolean },
'read_lint_errors': { uri: URI },
// ---
'edit_file': { uri: URI, changeDescription: string },
'edit_file': { uri: URI, changeDiff: string },
'create_file_or_folder': { uri: URI, isFolder: boolean },
'delete_file_or_folder': { uri: URI, isRecursive: boolean, isFolder: boolean },
// ---