add accept|reject on edit tool!

This commit is contained in:
Andrew Pareles 2025-05-08 02:21:55 -07:00
parent 42391a33c8
commit d00c478c5b
7 changed files with 179 additions and 81 deletions

View file

@ -1124,8 +1124,8 @@ class EditCodeService extends Disposable implements IEditCodeService {
return
}
public async callBeforeStartApplying(opts: CallBeforeStartApplyingOpts) {
const uri = this._getURIBeforeStartApplying(opts)
public async callBeforeApplyOrEdit(givenURI: URI | 'current') {
const uri = this._uriOfGivenURI(givenURI)
if (!uri) return
await this._voidModelService.initializeModel(uri)
await this._voidModelService.saveModel(uri) // save the URI
@ -1200,7 +1200,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
}
public instantlyApplyNewContent({ uri, newContent }: { uri: URI, newContent: string }) {
public instantlyRewriteFile({ uri, newContent }: { uri: URI, newContent: string }) {
// start diffzone
const res = this._startStreamingDiffZone({
uri,

View file

@ -44,10 +44,10 @@ export interface IEditCodeService {
processRawKeybindingText(keybindingStr: string): string;
callBeforeStartApplying(opts: CallBeforeStartApplyingOpts): Promise<void>;
callBeforeApplyOrEdit(uri: URI | 'current'): Promise<void>;
startApplying(opts: StartApplyingOpts): [URI, Promise<void>] | null;
instantlyApplySearchReplaceBlocks(opts: { uri: URI; searchReplaceBlocks: string }): void;
instantlyApplyNewContent(opts: { uri: URI; newContent: string }): void;
instantlyRewriteFile(opts: { uri: URI; newContent: string }): void;
addCtrlKZone(opts: AddCtrlKOpts): number | undefined;
removeCtrlKZone(opts: { diffareaid: number }): void;

View file

@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef, Fragment } from 'react'
import { useAccessor, useCommandBarState, useCommandBarURIListener, useSettingsState } from '../util/services.js'
import { usePromise, useRefState } from '../util/helpers.js'
import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js'
@ -24,8 +24,9 @@ type IconButtonProps = {
Icon: LucideIcon
}
export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: IconButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: IconButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
return <button
disabled={disabled}
onClick={(e) => {
e.preventDefault();
@ -34,19 +35,19 @@ export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: Ico
}}
// border border-void-border-1 rounded
className={`
size-[18px]
p-[2px]
flex items-center justify-center
text-sm text-void-fg-3
hover:brightness-110
disabled:opacity-50 disabled:cursor-not-allowed
${className}
size-[18px]
p-[2px]
flex items-center justify-center
text-sm text-void-fg-3
hover:brightness-110
disabled:opacity-50 disabled:cursor-not-allowed
${className}
`}
{...props}
>
<Icon />
</button>
)
}
// export const IconShell2 = ({ onClick, title, Icon, disabled, className }: IconButtonProps) => (
@ -131,48 +132,41 @@ export const JumpToTerminalButton = ({ onClick }: { onClick: () => void }) => {
// state persisted for duration of react only
// TODO change this to use type `ChatThreads.applyBoxState[applyBoxId]`
const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} }
const _applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} }
const getUriBeingApplied = (applyBoxId: string) => {
return applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null
return _applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null
}
export const useApplyButtonState = ({ applyBoxId, uri }: { applyBoxId: string, uri: URI | 'current' }) => {
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId
export const useApplyStreamState = ({ applyBoxId }: { applyBoxId: string }) => {
const accessor = useAccessor()
const voidCommandBarService = accessor.get('IVoidCommandBarService')
const [_, rerender] = useState(0)
const getStreamState = useCallback(() => {
const uri = getUriBeingApplied(applyBoxId)
if (!uri) return 'idle-no-changes'
return voidCommandBarService.getStreamState(uri)
}, [voidCommandBarService, applyBoxId])
const [currStreamStateRef, setStreamState] = useRefState(getStreamState())
const setApplying = useCallback((uri: URI | undefined) => {
_applyingURIOfApplyBoxIdRef.current[applyBoxId] = uri ?? undefined
setStreamState(getStreamState())
}, [setStreamState, getStreamState, applyBoxId])
// listen for stream updates on this box
useCommandBarURIListener(useCallback((uri_) => {
const shouldUpdate = (
getUriBeingApplied(applyBoxId)?.fsPath === uri_.fsPath
|| (uri !== 'current' && uri.fsPath === uri_.fsPath)
)
if (shouldUpdate) {
rerender(c => c + 1)
const uri = getUriBeingApplied(applyBoxId)
if (uri?.fsPath === uri_.fsPath) {
setStreamState(getStreamState())
}
}, [applyBoxId, uri]))
const currStreamState = getStreamState()
}, [setStreamState, applyBoxId, getStreamState]))
return {
getStreamState,
isDisabled,
currStreamState,
}
return { currStreamStateRef, setApplying }
}
@ -202,10 +196,24 @@ const tooltipPropsForApplyBlock = ({ tooltipName, color = undefined, position =
'data-tooltip-offset': offset,
})
export const useEditToolStreamState = ({ applyBoxId, uri }: { applyBoxId: string, uri: URI }) => {
const accessor = useAccessor()
const voidCommandBarService = accessor.get('IVoidCommandBarService')
const [streamState, setStreamState] = useState(voidCommandBarService.getStreamState(uri))
// listen for stream updates on this box
useCommandBarURIListener(useCallback((uri_) => {
const shouldUpdate = uri.fsPath === uri_.fsPath
if (shouldUpdate) { setStreamState(voidCommandBarService.getStreamState(uri)) }
}, [voidCommandBarService, applyBoxId, uri]))
return { streamState, }
}
export const StatusIndicatorForApplyButton = ({ applyBoxId, uri }: { applyBoxId: string, uri: URI | 'current' } & React.HTMLAttributes<HTMLDivElement>) => {
const { currStreamState } = useApplyButtonState({ applyBoxId, uri })
const { currStreamStateRef } = useApplyStreamState({ applyBoxId })
const currStreamState = currStreamStateRef.current
const color = (
currStreamState === 'idle-no-changes' ? 'dark' :
@ -231,74 +239,88 @@ export const StatusIndicatorForApplyButton = ({ applyBoxId, uri }: { applyBoxId:
}
export const ApplyButtonsHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string, applyBoxId: string, uri: URI | 'current' }) => {
export const ApplyButtonsHTML = ({
codeStr,
applyBoxId,
uri,
}: {
codeStr: string,
applyBoxId: string,
} & ({
uri: URI | 'current';
})
) => {
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const metricsService = accessor.get('IMetricsService')
const {
currStreamState,
isDisabled,
getStreamState,
} = useApplyButtonState({ applyBoxId, uri })
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId
const { currStreamStateRef, setApplying } = useApplyStreamState({ applyBoxId })
const onClickSubmit = useCallback(async () => {
if (isDisabled) return
if (getStreamState() === 'streaming') return
const opts = {
if (currStreamStateRef.current === 'streaming') return
await editCodeService.callBeforeApplyOrEdit(uri)
const [newApplyingUri, applyDonePromise] = editCodeService.startApplying({
from: 'ClickApply',
applyStr: codeStr,
uri: uri,
startBehavior: 'reject-conflicts',
} as const
await editCodeService.callBeforeStartApplying(opts)
const [newApplyingUri, applyDonePromise] = editCodeService.startApplying(opts) ?? []
}) ?? []
console.log('setting!!!', newApplyingUri)
setApplying(newApplyingUri)
// catch any errors by interrupting the stream
applyDonePromise?.catch(e => {
const uri = getUriBeingApplied(applyBoxId)
if (uri) editCodeService.interruptURIStreaming({ uri: uri })
})
applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined
// rerender(c => c + 1)
metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only
}, [isDisabled, getStreamState, editCodeService, codeStr, uri, applyBoxId, metricsService])
}, [setApplying, currStreamStateRef, editCodeService, codeStr, uri, applyBoxId, metricsService])
const onInterrupt = useCallback(() => {
if (getStreamState() !== 'streaming') return
const onClickStop = useCallback(() => {
if (currStreamStateRef.current !== 'streaming') return
const uri = getUriBeingApplied(applyBoxId)
if (!uri) return
editCodeService.interruptURIStreaming({ uri })
metricsService.capture('Stop Apply', {})
}, [getStreamState, applyBoxId, editCodeService, metricsService])
}, [currStreamStateRef, applyBoxId, editCodeService, metricsService])
const onAccept = useCallback(() => {
const uri = getUriBeingApplied(applyBoxId)
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
}, [applyBoxId, editCodeService])
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri: uri, behavior: 'accept', removeCtrlKs: false })
}, [uri, applyBoxId, editCodeService])
const onReject = useCallback(() => {
const uri = getUriBeingApplied(applyBoxId)
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
}, [applyBoxId, editCodeService])
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri: uri, behavior: 'reject', removeCtrlKs: false })
}, [uri, applyBoxId, editCodeService])
const currStreamState = currStreamStateRef.current
console.log('currStreamState...', currStreamState)
if (currStreamState === 'streaming') {
return <IconShell1
Icon={Square}
onClick={onInterrupt}
onClick={onClickStop}
{...tooltipPropsForApplyBlock({ tooltipName: 'Stop' })}
/>
}
if (currStreamState === 'idle-no-changes') {
if (isDisabled) {
return null
}
if (currStreamState === 'idle-no-changes') {
return <IconShell1
Icon={Play}
onClick={onClickSubmit}
@ -307,6 +329,76 @@ export const ApplyButtonsHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string
}
if (currStreamState === 'idle-has-changes') {
return <Fragment>
<IconShell1
Icon={X}
onClick={onReject}
{...tooltipPropsForApplyBlock({ tooltipName: 'Remove' })}
/>
<IconShell1
Icon={Check}
onClick={onAccept}
{...tooltipPropsForApplyBlock({ tooltipName: 'Keep' })}
/>
</Fragment>
}
}
export const EditToolButtonsHTML = ({
codeStr,
applyBoxId,
uri,
type,
}: {
codeStr: string,
applyBoxId: string,
} & ({
uri: URI,
type: 'edit_file' | 'rewrite_file'
})
) => {
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const metricsService = accessor.get('IMetricsService')
const { streamState } = useEditToolStreamState({ applyBoxId, uri })
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Chat', settingsState) || !applyBoxId
const onClickSubmit = useCallback(async () => {
await editCodeService.callBeforeApplyOrEdit(uri)
if (type === 'edit_file') {
editCodeService.instantlyApplySearchReplaceBlocks({ uri, searchReplaceBlocks: codeStr })
}
else if (type === 'rewrite_file') {
editCodeService.instantlyRewriteFile({ uri, newContent: codeStr })
}
}, [type, editCodeService, codeStr, uri, applyBoxId, metricsService])
const onAccept = useCallback(() => {
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
}, [uri, applyBoxId, editCodeService])
const onReject = useCallback(() => {
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
}, [uri, applyBoxId, editCodeService])
if (isDisabled) return null
if (streamState === 'idle-no-changes') {
return <IconShell1
Icon={RotateCw}
onClick={onClickSubmit}
{...tooltipPropsForApplyBlock({ tooltipName: 'Reapply' })}
/>
}
if (streamState === 'idle-has-changes') {
return <>
<IconShell1
Icon={X}
@ -340,7 +432,8 @@ export const BlockCodeApplyWrapper = ({
}) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const { currStreamState } = useApplyButtonState({ applyBoxId, uri })
const { currStreamStateRef } = useApplyStreamState({ applyBoxId })
const currStreamState = currStreamStateRef.current
const name = uri !== 'current' ?

View file

@ -71,7 +71,7 @@ export const QuickEditChat = ({
startBehavior: 'keep-conflicts',
} as const
await editCodeService.callBeforeStartApplying(opts)
await editCodeService.callBeforeApplyOrEdit(opts)
const [newApplyingUri, applyDonePromise] = editCodeService.startApplying(opts) ?? []
// catch any errors by interrupting the stream
applyDonePromise?.catch(e => { if (newApplyingUri) editCodeService.interruptCtrlKStreaming({ diffareaid }) })

View file

@ -23,7 +23,7 @@ import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../co
import { AlertTriangle, File, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X, Flag, Copy as CopyIcon, Info, CirclePlus, Ellipsis, CircleEllipsis, Folder, ALargeSmall, TypeOutline, Text } from 'lucide-react';
import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js';
import { approvalTypeOfToolName, LintErrorItem, ToolApprovalType, toolApprovalTypes, ToolCallParams } from '../../../../common/toolsServiceTypes.js';
import { ApplyButtonsHTML, CopyButton, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyButtonState } from '../markdown/ApplyBlockHoverButtons.js';
import { CopyButton, EditToolButtonsHTML, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyStreamState, useEditToolStreamState } from '../markdown/ApplyBlockHoverButtons.js';
import { IsRunningType } from '../../../chatThreadService.js';
import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js';
import { MAX_FILE_CHARS_PAGE, MAX_TERMINAL_INACTIVE_TIME, ToolName, toolNames } from '../../../../common/prompt/prompts.js';
@ -849,7 +849,7 @@ const EditTool = ({ toolMessage, threadId, messageIdx, content }: Parameters<Res
const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor)
const icon = null
const { rawParams, params } = toolMessage
const { rawParams, params, name } = toolMessage
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, }
if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') {
@ -872,6 +872,7 @@ const EditTool = ({ toolMessage, threadId, messageIdx, content }: Parameters<Res
applyBoxId={applyBoxId}
uri={params.uri}
codeStr={content}
toolName={name}
/>
// add children
@ -1620,14 +1621,13 @@ const BottomChildren = ({ children, title }: { children: React.ReactNode, title:
}
const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr }: { applyBoxId: string, uri: URI, codeStr: string }) => {
const { currStreamState } = useApplyButtonState({ applyBoxId, uri })
const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr, toolName }: { applyBoxId: string, uri: URI, codeStr: string, toolName: 'edit_file' | 'rewrite_file' }) => {
const { streamState } = useEditToolStreamState({ applyBoxId, uri })
return <div className='flex items-center gap-1'>
<StatusIndicatorForApplyButton applyBoxId={applyBoxId} uri={uri} />
<JumpToFileButton uri={uri} />
{currStreamState === 'idle-no-changes' && <CopyButton codeStr={codeStr} toolTipName='Copy' />}
{streamState === 'idle-no-changes' && <CopyButton codeStr={codeStr} toolTipName='Copy' />}
<EditToolButtonsHTML type={toolName} codeStr={codeStr} applyBoxId={applyBoxId} uri={uri} />
</div>
}

View file

@ -9,10 +9,13 @@ type ReturnType<T> = [
// use this if state might be too slow to catch
export const useRefState = <T,>(initVal: T): ReturnType<T> => {
const [_, _setState] = useState(false)
// this actually makes a difference being an int, not a boolean.
// if it's a boolean and changes happen to fast, it goes with old values and leads to *very* weird bugs (like returning JSX, but not actually rendering it)
const [_s, _setState] = useState(0)
const ref = useRef<T>(initVal)
const setState = useCallback((newVal: T) => {
_setState(n => !n) // call rerender
_setState(n => n + 1) // call rerender
ref.current = newVal
}, [])
return [ref, setState]

View file

@ -400,7 +400,8 @@ export class ToolsService implements IToolsService {
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and ask the user to resume later.`)
}
editCodeService.instantlyApplyNewContent({ uri, newContent })
await editCodeService.callBeforeApplyOrEdit(uri)
editCodeService.instantlyRewriteFile({ uri, newContent })
// at end, get lint errors
const lintErrorsPromise = Promise.resolve().then(async () => {
await timeout(2000)
@ -415,6 +416,7 @@ export class ToolsService implements IToolsService {
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`Another LLM is currently making changes to this file. Please stop streaming for now and ask the user to resume later.`)
}
await editCodeService.callBeforeApplyOrEdit(uri)
editCodeService.instantlyApplySearchReplaceBlocks({ uri, searchReplaceBlocks })
// at end, get lint errors