tool UI progress + terminal fix + misc fixes

This commit is contained in:
Andrew Pareles 2025-03-12 00:46:58 -07:00
parent 1ef8011bb0
commit 11d376325e
11 changed files with 388 additions and 266 deletions

View file

@ -189,10 +189,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
private readonly _onDidChangeCurrentThread = new Emitter<void>();
readonly onDidChangeCurrentThread: Event<void> = this._onDidChangeCurrentThread.event;
readonly streamState: ThreadStreamState = {}
private readonly _onDidChangeStreamState = new Emitter<{ threadId: string }>();
readonly onDidChangeStreamState: Event<{ threadId: string }> = this._onDidChangeStreamState.event;
readonly streamState: ThreadStreamState = {}
state: ThreadsState // allThreads is persisted, currentThread is not
constructor(
@ -445,7 +445,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// TODO!!! test rejection
// if (Math.random() > 0) throw new Error('TESTING')
const errorMessage = 'Tool call was rejected by the user.'
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, })
this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'rejected', params: toolParams, value: errorMessage }, })
shouldSendAnotherMessage = false // interrupt flow by rejecting
res_()
return

View file

@ -53,7 +53,7 @@ const configOfBG = (color: Color) => {
const greenBG = new Color(new RGBA(155, 185, 85, .1)); // default is RGBA(155, 185, 85, .2)
registerColor('void.greenBG', configOfBG(greenBG), '', true);
const redBG = new Color(new RGBA(255, 0, 0, .2)); // default is RGBA(255, 0, 0, .2)
const redBG = new Color(new RGBA(255, 0, 0, .05)); // default is RGBA(255, 0, 0, .2)
registerColor('void.redBG', configOfBG(redBG), '', true);
const sweepBG = new Color(new RGBA(100, 100, 100, .2));

View file

@ -656,52 +656,69 @@ export const SelectedFiles = (
const ReasoningComponent = ({ children }: { children: React.ReactNode }) => {
const [isOpen, setIsOpen] = useState(false)
return children
}
const ToolComponent = ({
type ToolHeaderParams = {
icon?: React.ReactNode;
title: string;
desc1: string;
desc2?: React.ReactNode;
isError?: boolean;
requestToolId?: string;
numResults?: number;
children?: React.ReactNode;
isLastMessage?: boolean;
onClick?: () => void;
}
const ToolHeaderComponent = ({
icon,
title,
desc1,
desc2,
numResults,
children,
isError,
requestToolId,
isLastMessage,
onClick,
}: {
title: string;
desc1: string;
desc2?: React.ReactNode;
numResults?: number;
children?: React.ReactNode;
onClick?: () => void;
}) => {
}: ToolHeaderParams) => {
const [isExpanded, setIsExpanded] = useState(false);
const isDropdown = !!children
const isClickable = !!isDropdown || !!onClick
return (
<div className="select-none">
<div className="border border-void-border-3 rounded px-2 py-1 bg-void-bg-2-alt overflow-hidden">
<div
className={`flex items-center min-h-[24px] ${isClickable ? 'cursor-pointer hover:brightness-125 transition-all duration-150' : ''} ${!isDropdown ? 'mx-1' : ''}`}
onClick={() => {
if (children) { setIsExpanded(v => !v); }
if (onClick) { onClick(); }
}}
>
{isDropdown && (
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-4 w-4 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isExpanded ? 'rotate-90' : ''}`}
/>
)}
<div className="flex items-center justify-between w-full flex-nowrap whitespace-nowrap gap-x-2">
<div className="flex items-center gap-x-2">
<span className="text-void-fg-3">{title}</span>
<span className="text-void-fg-4 text-xs italic">{desc1}</span>
</div>
return (<div className=''>
<div className="border border-void-border-3 rounded px-2 py-1 bg-void-bg-2-alt overflow-hidden">
{/* header */}
<div
className={`select-none flex items-center min-h-[24px] ${isClickable ? 'cursor-pointer hover:brightness-125 transition-all duration-150' : ''} ${!isDropdown ? 'mx-1' : ''}`}
onClick={() => {
if (children) { setIsExpanded(v => !v); }
if (onClick) { onClick(); }
}}
>
{isDropdown && (
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-4 w-4 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isExpanded ? 'rotate-90' : ''}`}
/>
)}
<div className="flex items-center justify-between w-full flex-nowrap whitespace-nowrap gap-x-2">
<div className="flex items-center gap-x-2">
<span className="text-void-fg-3">{title}</span>
<span className="text-void-fg-4 text-xs italic">{desc1}</span>
</div>
<div
// the py-1 here makes sure all elements in the container have py-2 total. this makes a nice animation effect during transition.
className={`overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'opacity-100 py-1' : 'max-h-0 opacity-0'}`}
>
<div className="flex items-center gap-x-2">
{desc2 && <span className="text-void-fg-4 text-xs">
{desc2}
@ -711,19 +728,23 @@ const ToolComponent = ({
{`(`}{numResults}{` result`}{numResults !== 1 ? 's' : ''}{`)`}
</span>
)}
{isError && <AlertTriangle className='text-void-warning opacity-90 flex-shrink-0' size={12} />}
</div>
</div>
</div>
<div
// the py-1 here makes sure all elements in the container have py-2 total. this makes a nice animation effect during transition.
className={`overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'opacity-100 py-1' : 'max-h-0 opacity-0'}`}
>
<div className="text-xs text-void-fg-4 px-2 py-1 bg-black bg-opacity-20 border border-void-border-4 border-opacity-50 rounded-sm">
{children}
</div>
</div>
{/* children */}
<div
// the py-1 here makes sure all elements in the container have py-2 total. this makes a nice animation effect during transition.
className={`overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'opacity-100 py-1' : 'max-h-0 opacity-0'}`}
>
<div className="text-void-fg-4 px-2 py-1 bg-black bg-opacity-20 border border-void-border-4 border-opacity-50 rounded-sm">
{children}
</div>
</div>
</div>
{!requestToolId ? null : <ToolRequestAcceptRejectButtons voidToolId={requestToolId} isLastMessage={!!isLastMessage} />}
</div>
);
};
@ -994,42 +1015,15 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx, isLast
const ToolError = ({ title, desc1, errorMessage }: { title: string, desc1: string, errorMessage: string }) => {
return (
// px-2 py-1
// <div className='flex gap-2 p-3 border border-void-border-3 text-void-fg-3 rounded bg-void-bg-2-alt'>
// <AlertTriangle className='text-void-warning opacity-90 flex-shrink-0' size={20} />
// <div className='flex flex-col'>
// <span className='mb-1'>{title + ' error'}</span>
// <div className='text-sm opacity-90'>{errorMessage}</div>
// </div>
// </div>
<ToolComponent
title={title}
desc1={desc1}
desc2={
<span className="flex items-center flex-nowrap gap-1">
<AlertTriangle className='text-void-warning opacity-90 flex-shrink-0' size={12} />
Error
</span>
}
>
<div className='text-xs text-wrap whitespace-pre-wrap break-all break-words'>{errorMessage}</div>
</ToolComponent>
)
}
const toolNameToTitle: Record<ToolName, string> = {
'read_file': 'Read file',
'list_dir': 'Inspected folder',
'pathname_search': 'Searched by file name',
'search': 'Searched files',
'create_uri': 'Created file',
'delete_uri': 'Deleted file',
'edit': 'Edited file',
'terminal_command': 'Ran terminal command'
'list_dir': 'Inspect folder',
'pathname_search': 'Search by file name',
'search': 'Search',
'create_uri': 'Create file',
'delete_uri': 'Delete file',
'edit': 'Edit file',
'terminal_command': 'Run terminal command'
}
const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName] | undefined): string => {
@ -1067,7 +1061,7 @@ const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName
}
const ToolRequestAcceptRejectButtons = ({ toolRequest, messageIdx, isLast }: { toolRequest: ToolRequestApproval<ToolName> } & Omit<ChatBubbleProps, 'chatMessage'>) => {
const ToolRequestAcceptRejectButtons = ({ voidToolId, isLastMessage: isLast }: { voidToolId: string, isLastMessage: boolean }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const metricsService = accessor.get('IMetricsService')
@ -1076,16 +1070,16 @@ const ToolRequestAcceptRejectButtons = ({ toolRequest, messageIdx, isLast }: { t
const [requestState, setRequestState] = useState<'accepted' | 'rejected' | 'awaiting_response'>(initRequestState)
const onAccept = useCallback(() => {
chatThreadsService.approveTool(toolRequest.voidToolId)
chatThreadsService.approveTool(voidToolId)
setRequestState('accepted')
metricsService.capture('Tool Request Accepted', {})
}, [chatThreadsService, toolRequest.voidToolId, metricsService])
}, [chatThreadsService, voidToolId, metricsService])
const onReject = useCallback(() => {
chatThreadsService.rejectTool(toolRequest.voidToolId)
chatThreadsService.rejectTool(voidToolId)
setRequestState('rejected')
metricsService.capture('Tool Request Rejected', {})
}, [chatThreadsService, toolRequest.voidToolId, metricsService])
}, [chatThreadsService, voidToolId, metricsService])
const approveButton = (
<button
@ -1130,7 +1124,7 @@ const ToolRequestAcceptRejectButtons = ({ toolRequest, messageIdx, isLast }: { t
}
const toolNameToComponent: { [T in ToolName]: {
requestWrapper: T extends ToolNameWithApproval ? ((props: { toolRequest: ToolRequestApproval<T> }) => React.ReactNode) : null,
requestWrapper: T extends ToolNameWithApproval ? ((props: { toolRequest: ToolRequestApproval<T>, isLastMessage: boolean }) => React.ReactNode) : null,
resultWrapper: (props: { toolMessage: ToolMessage<T> }) => React.ReactNode,
} } = {
'read_file': {
@ -1139,25 +1133,27 @@ const toolNameToComponent: { [T in ToolName]: {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const { uri } = toolMessage.result.params ?? {}
const desc1 = uri ? getBasename(uri.fsPath) : '';
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { value, params } = toolMessage.result
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
if (toolMessage.result.value.hasNextPage) componentParams.desc2 = `(AI can scroll for more)`
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
const { value, params } = toolMessage.result
return <ToolComponent title={title} desc1={desc1}>
<div
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }}
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
{params.uri.fsPath}
</div>
{toolMessage.result.value.hasNextPage && (<div className="italic">AI can scroll for more content...</div>)}
</ToolComponent>
return <ToolHeaderComponent {...componentParams} />
},
},
'list_dir': {
@ -1168,37 +1164,44 @@ const toolNameToComponent: { [T in ToolName]: {
const explorerService = accessor.get('IExplorerService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { value, params } = toolMessage.result
componentParams.numResults = value.children?.length
componentParams.children = <>
{value.children?.map((child, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('workbench.view.explorer');
explorerService.select(child.uri, true);
}}
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
{`${child.name}${child.isDirectory ? '/' : ''}`}
</div>
))}
{value.hasNextPage && (
<div className="italic">
{value.itemsRemaining} more items...
</div>
)}
</>
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
const { value, params } = toolMessage.result
return <ToolComponent
title={title}
desc1={desc1}
numResults={value.children?.length}
>
{value.children?.map((child, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('workbench.view.explorer');
explorerService.select(child.uri, true);
}}
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
{`${child.name}${child.isDirectory ? '/' : ''}`}
</div>
))}
{value.hasNextPage && (
<div className="italic">
{value.itemsRemaining} more items...
</div>
)}
</ToolComponent>
return <ToolHeaderComponent {...componentParams} />
}
},
'pathname_search': {
@ -1208,19 +1211,16 @@ const toolNameToComponent: { [T in ToolName]: {
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
}
if (toolMessage.result.type === 'rejected') return null
const { value, params } = toolMessage.result
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
return (
<ToolComponent
title={title}
desc1={desc1}
numResults={value.uris.length}
>
if (toolMessage.result.type !== 'error') {
const { value, params } = toolMessage.result
componentParams.children = <>
{value.uris.map((uri, i) => (
<div
key={i}
@ -1238,8 +1238,15 @@ const toolNameToComponent: { [T in ToolName]: {
More results available...
</div>
)}
</ToolComponent>
)
</>
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
return <ToolHeaderComponent {...componentParams} />
}
},
'search': {
@ -1249,19 +1256,17 @@ const toolNameToComponent: { [T in ToolName]: {
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
}
if (toolMessage.result.type === 'rejected') return null
const { value, params } = toolMessage.result
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
return (
<ToolComponent
title={title}
desc1={desc1}
numResults={value.uris.length}
>
if (toolMessage.result.type !== 'error') {
const { value, params } = toolMessage.result
componentParams.children = <>
{value.uris.map((uri, i) => (
<div key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
@ -1272,153 +1277,214 @@ const toolNameToComponent: { [T in ToolName]: {
</div>
))}
{value.hasNextPage && (<div className="italic">More results available...</div>)}
</ToolComponent>
)
</>
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
return <ToolHeaderComponent {...componentParams} />
}
},
// ---
'create_uri': {
requestWrapper: ({ toolRequest }) => {
requestWrapper: ({ toolRequest, isLastMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolRequest.name]
const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params)
return <ToolComponent title={title} desc1={desc1} />
const icon = null
const isError = false
const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isLastMessage, requestToolId: toolRequest.voidToolId }
const { params } = toolRequest
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
return <ToolHeaderComponent title={title} desc1={desc1} />
},
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { params } = toolMessage.result
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
const { params } = toolMessage.result
return (
<ToolComponent
title={title}
desc1={desc1}
onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }}
/>
)
return <ToolHeaderComponent {...componentParams} />
}
},
'delete_uri': {
requestWrapper: ({ toolRequest }) => {
requestWrapper: ({ toolRequest, isLastMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolRequest.name]
const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params)
return <ToolComponent title={title} desc1={desc1}
onClick={() => { commandService.executeCommand('vscode.open', toolRequest.params.uri, { preview: true }) }}
/>
const icon = null
const isError = false
const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isLastMessage, requestToolId: toolRequest.voidToolId }
const { params } = toolRequest
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
return <ToolHeaderComponent {...componentParams} />
},
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { params } = toolMessage.result
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
const { params } = toolMessage.result
return (
<ToolComponent
title={title}
desc1={desc1}
onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }}
/>
)
return <ToolHeaderComponent {...componentParams} />
}
},
'edit': {
requestWrapper: ({ toolRequest }) => {
requestWrapper: ({ toolRequest, isLastMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolRequest.name]
const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params)
return <ToolComponent title={title} desc1={desc1}
onClick={() => { commandService.executeCommand('vscode.open', toolRequest.params.uri, { preview: true }) }}
>
<ChatMarkdownRender string={toolRequest.params.changeDescription} chatMessageLocation={undefined} />
</ToolComponent>
const icon = null
const isError = false
const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isLastMessage, requestToolId: toolRequest.voidToolId }
const { params } = toolRequest
componentParams.children = <ChatMarkdownRender string={params.changeDescription} chatMessageLocation={undefined} />
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
return <ToolHeaderComponent {...componentParams} />
},
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { params } = toolMessage.result
componentParams.children = <ChatMarkdownRender string={params.changeDescription} chatMessageLocation={undefined} />
componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
const { params } = toolMessage.result
return (
<ToolComponent
title={title}
desc1={desc1}
onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }}
/>
)
return <ToolHeaderComponent {...componentParams} />
}
},
'terminal_command': {
requestWrapper: ({ toolRequest }) => {
requestWrapper: ({ toolRequest, isLastMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const terminalToolsService = accessor.get('ITerminalToolService')
const title = toolNameToTitle[toolRequest.name]
const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params)
const icon = null
const isError = false
const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isLastMessage, requestToolId: toolRequest.voidToolId }
const { proposedTerminalId } = toolRequest.params
if (terminalToolsService.terminalExists(proposedTerminalId))
componentParams.onClick = () => terminalToolsService.openTerminal(proposedTerminalId)
if (!toolRequest.params.waitForCompletion)
componentParams.desc2 = '(background task)'
const { waitForCompletion, command, proposedTerminalId } = toolRequest.params
return <ToolComponent title={title} desc1={desc1} desc2={waitForCompletion ? null : '(background task)'}
// TODO!!! open terminal
/>
return <ToolHeaderComponent {...componentParams} />
},
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const terminalToolsService = accessor.get('ITerminalToolService')
const title = toolNameToTitle[toolMessage.name]
const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params)
const icon = null
if (toolMessage.result.type === 'error') {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
if (toolMessage.result.type === 'rejected') return null
const isError = toolMessage.result.type === 'error'
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
if (toolMessage.result.type !== 'error') {
const { command } = toolMessage.result.params
const { terminalId, resolveReason, result } = toolMessage.result.value
componentParams.children = <div className='font-mono whitespace-pre text-nowrap text-xs overflow-auto bg-void-bg-1'>
<div
className='cursor-pointer'
onClick={() => terminalToolsService.openTerminal(terminalId)}
>$ {command}</div>
<hr className='border-void-border-1' />
{resolveReason.type === 'bgtask' ? 'Result so far:\n' : null}
{result}
{resolveReason.type === 'done' ? (resolveReason.exitCode !== 0 ? `\nError: exit code ${resolveReason.exitCode}` : null)
: resolveReason.type === 'bgtask' ? null :
resolveReason.type === 'timeout' ? `\n(partial results; request timed out)` :
resolveReason.type === 'toofull' ? `\n(truncated)`
: null
}
</div>
if (resolveReason.type === 'bgtask')
componentParams.desc2 = '(background task)'
}
else {
componentParams.children = <>
{toolMessage.result.value}
</>
}
// TODO!!! open terminal
const { command } = toolMessage.result.params
const { terminalId, resolveReason, result } = toolMessage.result.value
return (
<ToolComponent
title={title}
desc1={desc1}
desc2={resolveReason.type === 'bgtask' ? '(background task)' : null}
>
<div className='font-mono whitespace-pre text-nowrap text-xs overflow-auto bg-void-bg-1'>
{resolveReason.type === 'bgtask' ? 'Result so far:' : null}
{result}
{
resolveReason.type === 'done' ? (resolveReason.exitCode !== 0 ? `
Error: exit code ${resolveReason.exitCode}` : null)
: resolveReason.type === 'bgtask' ? null :
resolveReason.type === 'timeout' ? `
(partial results; request timed out)` :
resolveReason.type === 'toofull' ? `
(truncated)`
: null
}
</div>
</ToolComponent>
)
return <ToolHeaderComponent {...componentParams} />
}
}
};
@ -1448,10 +1514,8 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx, isLast }: ChatBubblePr
/>
}
else if (role === 'tool_request') {
const isLastMessage = true // TODO!!! fix this
if (!isLastMessage) return null
const ToolRequestWrapper = toolNameToComponent[chatMessage.name].requestWrapper as React.FC<{ toolRequest: any }> // ts isnt smart enough...
return <ToolRequestWrapper toolRequest={chatMessage} />
const ToolRequestWrapper = toolNameToComponent[chatMessage.name].requestWrapper as React.FC<{ toolRequest: any, isLastMessage: boolean }> // ts isnt smart enough...
return <ToolRequestWrapper toolRequest={chatMessage} isLastMessage={isLast} />
}
else if (role === 'tool') {

View file

@ -45,6 +45,7 @@ import { IPathService } from '../../../../../../../workbench/services/path/commo
import { IMetricsService } from '../../../../../../../workbench/contrib/void/common/metricsService.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { IChatThreadService, ThreadsState, ThreadStreamState } from '../../../chatThreadService.js'
import { ITerminalToolService } from '../../../terminalToolService.js'
@ -232,6 +233,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IConfigurationService: accessor.get(IConfigurationService),
IPathService: accessor.get(IPathService),
IMetricsService: accessor.get(IMetricsService),
ITerminalToolService: accessor.get(ITerminalToolService)
} as const
return reactAccessor

View file

@ -8,7 +8,7 @@ import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
import { ITerminalService, ITerminalInstance, ITerminalGroupService } from '../../../../workbench/contrib/terminal/browser/terminal.js';
import { ITerminalService, ITerminalInstance } from '../../../../workbench/contrib/terminal/browser/terminal.js';
import { ResolveReason } from '../common/toolsServiceTypes.js';
import { MAX_TERMINAL_CHARS_PAGE, TERMINAL_BG_WAIT_TIME, TERMINAL_TIMEOUT_TIME } from './toolsService.js';
@ -17,8 +17,10 @@ import { MAX_TERMINAL_CHARS_PAGE, TERMINAL_BG_WAIT_TIME, TERMINAL_TIMEOUT_TIME }
export interface ITerminalToolService {
readonly _serviceBrand: undefined;
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: ResolveReason }>;
listTerminalIds(): string[];
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: ResolveReason }>;
openTerminal(terminalId: string): Promise<void>
terminalExists(terminalId: string): boolean
}
export const ITerminalToolService = createDecorator<ITerminalToolService>('TerminalToolService');
@ -54,16 +56,31 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
constructor(
@ITerminalService private readonly terminalService: ITerminalService,
@ITerminalGroupService private readonly terminalGroupService: ITerminalGroupService,
) {
super();
// runs on ALL terminals for simplicity
const initializeTerminal = (terminal: ITerminalInstance) => {
// when exit, remove
const d = terminal.onExit(() => {
const terminalId = idOfName(terminal.title)
if (terminalId !== null && (terminalId in this.terminalInstanceOfId)) delete this.terminalInstanceOfId[terminalId]
d.dispose()
})
}
// initialize any terminals that are already open
for (const terminal of terminalService.instances) {
const proposedTerminalId = idOfName(terminal.title)
if (proposedTerminalId) this.terminalInstanceOfId[proposedTerminalId] = terminal
initializeTerminal(terminal)
}
console.log('Initialized terminal instances:', this.terminalInstanceOfId)
this._register(
terminalService.onDidCreateInstance(terminal => { initializeTerminal(terminal) })
)
}
@ -91,16 +108,47 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
private async _getOrCreateTerminal(proposedTerminalId: string) {
// if terminal ID exists, return it
if (proposedTerminalId in this.terminalInstanceOfId) return { terminalId: proposedTerminalId, didCreateTerminal: false }
// create new terminal and return its ID
const terminalId = this.getValidNewTerminalId();
const terminal = await this.terminalService.createTerminal({
location: TerminalLocation.Panel,
config: { name: nameOfId(terminalId), title: nameOfId(terminalId) }
config: { name: nameOfId(terminalId), title: nameOfId(terminalId) },
})
// when a new terminal is created, there is an initial command that gets run which is empty, wait for it to end before returning
const disposables: IDisposable[] = []
const waitForMount = new Promise<void>(res => {
let data = ''
const d = terminal.onData(newData => {
data += newData
if (isCommandComplete(data)) { res() }
})
disposables.push(d)
})
const waitForTimeout = new Promise<void>(res => { setTimeout(() => { res() }, 1000) })
await Promise.any([waitForMount, waitForTimeout,])
disposables.forEach(d => d.dispose())
this.terminalInstanceOfId[terminalId] = terminal
return { terminalId, didCreateTerminal: true }
}
terminalExists(terminalId: string): boolean {
return terminalId in this.terminalInstanceOfId
}
openTerminal: ITerminalToolService['openTerminal'] = async (terminalId) => {
if (!terminalId) return
const terminal = this.terminalInstanceOfId[terminalId]
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
}
runCommand: ITerminalToolService['runCommand'] = async (command, proposedTerminalId, waitForCompletion) => {
@ -109,18 +157,22 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
const terminal = this.terminalInstanceOfId[terminalId];
if (!terminal) throw new Error(`Unexpected internal error: Terminal with ID ${terminalId} did not exist.`);
this.terminalGroupService.focusInstance(terminal)
// focus the terminal about to run
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
let result: string = ''
let resolveReason: ResolveReason | undefined = undefined
const disposables: IDisposable[] = []
// onFullPage
const waitUntilFullPage = new Promise<void>((res, rej) => {
const d1 = terminal.onData(async newData => {
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(async newData => {
if (resolveReason) return
result += newData
// onPageFull
if (result.length > MAX_TERMINAL_CHARS_PAGE) {
result = result.substring(0, MAX_TERMINAL_CHARS_PAGE)
await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C
@ -128,14 +180,8 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
res()
return
}
})
disposables.push(d1)
})
// onDone
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(newData => {
if (resolveReason) return
// onDone
const isDone = isCommandComplete(result)
if (isDone) {
resolveReason = { type: 'done', exitCode: isDone.exitCode }
@ -157,12 +203,12 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C
resolveReason = { type: waitForCompletion ? 'timeout' : 'bgtask' }
res()
return
}, (waitForCompletion ? TERMINAL_TIMEOUT_TIME : TERMINAL_BG_WAIT_TIME) * 1000)
})
await Promise.any([
waitUntilDone,
waitUntilFullPage,
waitUntilTimeout,
])
@ -170,14 +216,11 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.')
console.log('res', { terminalId, didCreateTerminal, result, resolveReason })
result = removeAnsiEscapeCodes(result)
.split('\n').slice(1, -1) // remove first and last line (first = command, last = andrewpareles/void %)
.join('\n')
console.log('TerminalToolService: Command completed:', JSON.stringify(result))
return { terminalId, didCreateTerminal, result, resolveReason }
}

View file

@ -27,7 +27,7 @@ type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Tool
// pagination info
const MAX_FILE_CHARS_PAGE = 50_000
const MAX_CHILDREN_URIs_PAGE = 500
export const MAX_TERMINAL_CHARS_PAGE = 50_000
export const MAX_TERMINAL_CHARS_PAGE = 20_000
export const TERMINAL_TIMEOUT_TIME = 15
export const TERMINAL_BG_WAIT_TIME = 1
@ -116,7 +116,7 @@ const validateStr = (argName: string, value: unknown) => {
}
// TODO!!!! check to make sure in workspace
// We are NOT checking to make sure in workspace
const validateURI = (uriStr: unknown) => {
if (typeof uriStr !== 'string') throw new Error('Error: provided uri must be a string.')
@ -152,6 +152,14 @@ const validateWaitForCompletion = (b: unknown) => {
}
return true // default is true
}
const checkIfIsFolder = (uriStr: string) => {
uriStr = uriStr.trim()
if (uriStr.endsWith('/') || uriStr.endsWith('\\')) return true
return false
}
export interface IToolsService {
readonly _serviceBrand: undefined;
validateParams: ValidateParams;
@ -224,17 +232,21 @@ export class ToolsService implements IToolsService {
create_uri: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr } = o
const uri = validateURI(uriStr)
return { uri }
const { uri: uriUnknown } = o
const uri = validateURI(uriUnknown)
const uriStr = validateStr('uri', uriUnknown)
const isFolder = checkIfIsFolder(uriStr)
return { uri, isFolder }
},
delete_uri: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr, params: paramsStr } = o
const uri = validateURI(uriStr)
const { uri: uriUnknown, params: paramsStr } = o
const uri = validateURI(uriUnknown)
const isRecursive = validateRecursiveParamStr(paramsStr)
return { uri, isRecursive }
const uriStr = validateStr('uri', uriUnknown)
const isFolder = checkIfIsFolder(uriStr)
return { uri, isRecursive, isFolder }
},
edit: async (params: string) => {
@ -242,7 +254,6 @@ export class ToolsService implements IToolsService {
const { uri: uriStr, changeDescription: changeDescriptionUnknown } = o
const uri = validateURI(uriStr)
const changeDescription = validateStr('changeDescription', changeDescriptionUnknown)
return { uri, changeDescription }
},

View file

@ -9,7 +9,12 @@ export type ToolMessage<T extends ToolName> = {
paramsStr: string; // internal use
id: string; // apis require this tool use id
content: string; // give this result to LLM
result: { type: 'success'; params: ToolCallParams[T]; value: ToolResultType[T], } | { type: 'error'; params: ToolCallParams[T] | undefined; value: string }; // give this result to user
// if rejected, don't show in chat
result:
| { type: 'success'; params: ToolCallParams[T]; value: ToolResultType[T], }
| { type: 'error'; params: ToolCallParams[T] | undefined; value: string }
| { type: 'rejected'; params: ToolCallParams[T]; value: string }
}
export type ToolRequestApproval<T extends ToolName> = {
role: 'tool_request';

View file

@ -17,9 +17,8 @@ export const tripleTick = ['```', '```']
export const editToolDesc_toolDescription = `\
A high level description of the change you'd like to make in the file. This description will be handed to a dumber, faster model that will quickly apply the change. \
Typically the best description you can give here is a high level view of the final code you'd like to see. For example, you can write code excerpt(s) with "// ... existing code ..." comments to help you write less. \
However, you are allowed to describe the change using whatever text/language you like, especially if the change is better described without code. \
Do NOT output the whole file if possible, and try to write as LITTLE as needed to describe the change.`
Typically the best description you can give here is a single code block of the form:\n${tripleTick[0]}\n// ... existing code ...\n{{change 1}}\n// ... existing code ...\n{{change2}}\n// ... existing code ...\n{{change 3}}\n...\n${tripleTick[1]}.\
Do NOT output the whole file here if possible, and try to write as LITTLE as needed to describe the change.`

View file

@ -148,8 +148,8 @@ export type ToolCallParams = {
'search': { queryStr: string, pageNumber: number },
// ---
'edit': { uri: URI, changeDescription: string },
'create_uri': { uri: URI },
'delete_uri': { uri: URI, isRecursive: boolean },
'create_uri': { uri: URI, isFolder: boolean },
'delete_uri': { uri: URI, isRecursive: boolean, isFolder: boolean },
'terminal_command': { command: string, proposedTerminalId: string, waitForCompletion: boolean },
}

View file

@ -385,14 +385,13 @@ export type GlobalSettings = {
autoRefreshModels: boolean;
aiInstructions: string;
enableAutocomplete: boolean;
chatMode: ChatMode;
}
export const defaultGlobalSettings: GlobalSettings = {
autoRefreshModels: true,
aiInstructions: '',
enableAutocomplete: false,
chatMode: 'agent',
}
export type GlobalSettingName = keyof GlobalSettings

View file

@ -285,7 +285,6 @@ const prepareMessages_tools_anthropic = ({ messages }: { messages: InternalLLMCh
role: 'user',
content: [
...[{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content || EMPTY_TOOL_CONTENT }] as const,
...currMsg.content ? [{ type: 'text', text: currMsg.content }] as const : [],
]
}
}