terminal progress + misc

This commit is contained in:
Andrew Pareles 2025-03-11 06:53:54 -07:00
parent ab81479340
commit d6d5f77183
8 changed files with 163 additions and 73 deletions

View file

@ -446,6 +446,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// 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 }, })
shouldSendAnotherMessage = false // interrupt flow by rejecting
res_()
return
}

View file

@ -50,10 +50,10 @@ const configOfBG = (color: Color) => {
return { dark: color, light: color, hcDark: color, hcLight: color, }
}
// gets converted to --vscode-void-greenBG, see void.css, asCssVariable
const greenBG = new Color(new RGBA(155, 185, 85, .3)); // default is RGBA(155, 185, 85, .2)
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, .3)); // default is RGBA(255, 0, 0, .2)
const redBG = new Color(new RGBA(255, 0, 0, .2)); // default is RGBA(255, 0, 0, .2)
registerColor('void.redBG', configOfBG(redBG), '', true);
const sweepBG = new Color(new RGBA(100, 100, 100, .2));
@ -1481,7 +1481,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
onUndo: () => { if (diffZone._streamState.isStreaming) rejApplyDonePromise(new Error('Edit was interrupted by pressing undo.')) }
})
// TODO replace these with whatever block we're on initially if already started (caching apply)
// TODO replace these with whatever block we're on initially if already started (if add caching of apply S/R blocks)
type SearchReplaceDiffAreaMetadata = {
originalBounds: [number, number], // 1-indexed
@ -1607,6 +1607,12 @@ class EditCodeService extends Disposable implements IEditCodeService {
shouldUpdateOrigStreamStyle = false
}
}
else {
// TODO!!! test this
// starting line is at least the number of lines in the generated code minus 1
const numLinesInOrig = block.orig.split('\n').length - 1
diffZone._streamState.line = Math.max(numLinesInOrig - 1, 1, diffZone._streamState.line ?? 1)
}
// must be done writing original to move on to writing streamed content
continue
}
@ -1615,37 +1621,22 @@ class EditCodeService extends Disposable implements IEditCodeService {
// if this is the first time we're seeing this block, add it as a diffarea so we can start streaming
if (!(blockNum in addedTrackingZoneOfBlockNum)) {
console.log('finding text in code...', { orig: block.orig })
const originalBounds = findTextInCode(block.orig, originalFileCode)
// if error
if (typeof originalBounds === 'string') {
console.log('TEXT NOT FOUND')
const content = errMsgOfInvalidStr(originalBounds, block.orig)
messages.push(
{ role: 'assistant', content: fullText, anthropicReasoning: null }, // latest output
{ role: 'user', content: content } // user explanation of what's wrong
)
// REVERT
// TODO!!!!! don't actually revert - we want to change this so it doesn't revert but isntead gives the current file contents
const numLines = this._getNumLines(uri)
if (numLines !== null) this._writeText(uri, originalFileCode,
{ startLineNumber: 1, startColumn: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER },
{ shouldRealignDiffAreas: false }
)
// reset state
diffZone.startLine = 1
diffZone.endLine = numLines ?? 1
if (diffZone._streamState.isStreaming) {
diffZone._streamState.line = 1
}
currStreamingBlockNum = 0
// REVERT THIS ONE BLOCK
// TODO!!! test this
latestStreamLocationMutable = null
shouldUpdateOrigStreamStyle = true
oldBlocks = []
addedTrackingZoneOfBlockNum.splice(0, Infinity) // clear the array
blocks.splice(blockNum, Infinity) // remove all blocks at and after this one
oldBlocks = blocks
// abort and resolve
shouldSendAnotherMessage = true

View file

@ -914,7 +914,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble
}
const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatBubbleProps & { chatMessage: ChatMessage & { role: 'assistant' } }) => {
const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx, isLast }: ChatBubbleProps & { chatMessage: ChatMessage & { role: 'assistant' } }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
@ -930,8 +930,9 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB
messageIdx: messageIdx,
}
const isEmpty = !chatMessage.content && !chatMessage.reasoning // && !(isLast && isLoading) // TODO!!!!
if (isEmpty) return null
const isEmpty = !chatMessage.content && !chatMessage.reasoning
const isLastAndLoading = isLoading && isLast
if (isEmpty && !isLastAndLoading) return null
return <>
<div
@ -1023,7 +1024,7 @@ const toolNameToTitle: Record<ToolName, string> = {
'create_uri': 'Create file',
'delete_uri': 'Delete file',
'edit': 'Edit file',
'terminal_command': 'Ran terminal command'
'terminal_command': 'Run terminal command'
}
const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName] | undefined): string => {
@ -1335,8 +1336,10 @@ const toolNameToComponent: { [T in ToolName]: {
const commandService = accessor.get('ICommandService')
const title = toolNameToTitle[toolRequest.name]
const desc1 = toolNameToDesc(toolRequest.name, toolRequest.params)
return <DropdownComponent title={title} desc1={desc1}
// TODO!!! open the terminal with that ID
const { waitForCompletion, command, proposedTerminalId } = toolRequest.params
return <DropdownComponent title={title} desc1={desc1} desc2={waitForCompletion ? '(background task)' : null}
// TODO!!! open terminal
/>
},
resultWrapper: ({ toolMessage }) => {
@ -1349,19 +1352,32 @@ const toolNameToComponent: { [T in ToolName]: {
return <ToolError title={title} desc1={desc1} errorMessage={toolMessage.result.value} />
}
const { params } = toolMessage.result
const { command } = toolMessage.result.params
const { terminalId, resolveReason, result } = toolMessage.result.value
return (
<DropdownComponent
title={title}
desc1={desc1}
desc2={resolveReason.type === 'bgtask' ? '(background task)' : null}
>
<div
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
// TODO!!! open terminal
>
<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>
<ChatMarkdownRender string={''} chatMessageLocation={undefined} />
<div>
<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>
{resolveReason.type === 'bgtask' ? 'Result so far:' : null}
<ChatMarkdownRender string={`\`\`\`\n${result}\n\`\`\``} chatMessageLocation={undefined} />
{
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>
</div>
</DropdownComponent>
)
@ -1371,8 +1387,9 @@ const toolNameToComponent: { [T in ToolName]: {
type ChatBubbleMode = 'display' | 'edit'
type ChatBubbleProps = { chatMessage: ChatMessage, messageIdx: number, isLoading?: boolean, }
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: ChatBubbleProps) => {
type ChatBubbleProps = { chatMessage: ChatMessage, messageIdx: number, isLoading?: boolean, isLast: boolean }
const ChatBubble = ({ chatMessage, isLoading, messageIdx, isLast }: ChatBubbleProps) => {
const role = chatMessage.role
@ -1381,6 +1398,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: ChatBubbleProps) =>
chatMessage={chatMessage}
messageIdx={messageIdx}
isLoading={isLoading}
isLast={isLast}
/>
}
else if (role === 'assistant') {
@ -1388,6 +1406,7 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: ChatBubbleProps) =>
chatMessage={chatMessage}
messageIdx={messageIdx}
isLoading={isLoading}
isLast={isLast}
/>
}
else if (role === 'tool_request') {
@ -1508,7 +1527,7 @@ export const SidebarChat = () => {
const pastMessagesHTML = useMemo(() => {
return previousMessages.map((message, i) =>
<ChatBubble key={getChatBubbleId(currentThread.id, i)} chatMessage={message} messageIdx={i} />
<ChatBubble key={getChatBubbleId(currentThread.id, i)} chatMessage={message} messageIdx={i} isLast={!isStreaming} />
)
}, [previousMessages, currentThread])
@ -1524,6 +1543,7 @@ export const SidebarChat = () => {
anthropicReasoning: null,
}}
isLoading={isStreaming}
isLast={true}
/> : null
const allMessagesHTML = [...pastMessagesHTML, currStreamingMessageHTML]

View file

@ -33,7 +33,7 @@ export const SidebarThreadSelector = () => {
.filter(threadId => allThreads![threadId].messages.length !== 0)
return (
<div className="flex p-2 flex-col gap-y-1 max-h-[400px] overflow-y-auto">
<div className="flex p-2 flex-col gap-y-1 max-h-[200px] overflow-y-auto">
<div className="w-full relative flex justify-center items-center">
{/* title */}

View file

@ -3,23 +3,36 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js';
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
import { ITerminalService, ITerminalInstance } from '../../../../workbench/contrib/terminal/browser/terminal.js';
import { ITerminalService, ITerminalInstance, ITerminalGroupService } 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';
export interface ITerminalToolService {
readonly _serviceBrand: undefined;
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, contents: string }>;
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: ResolveReason }>;
listTerminalIds(): string[];
}
export const ITerminalToolService = createDecorator<ITerminalToolService>('TerminalToolService');
function isCommandComplete(output: string) {
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const completionMatch = output.match(/\]633;D(?:;(\d+))?/)
if (!completionMatch) { return false }
if (completionMatch[1] !== undefined) return { exitCode: parseInt(completionMatch[1]) }
return { exitCode: 0 }
}
const nameOfId = (id: string) => {
if (id === '1') return 'Void Agent'
return `Void Agent (${id})`
@ -40,11 +53,11 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
constructor(
@ITerminalService private readonly terminalService: ITerminalService,
@ITerminalGroupService private readonly terminalGroupService: ITerminalGroupService,
) {
super();
// initialize any terminals that are already open
for (const terminal of terminalService.instances) {
const proposedTerminalId = idOfName(terminal.title)
if (proposedTerminalId) this.terminalInstanceOfId[proposedTerminalId] = terminal
@ -82,7 +95,7 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
const terminal = await this.terminalService.createTerminal({
location: TerminalLocation.Panel,
config: { name: nameOfId(terminalId), title: nameOfId(terminalId) }
});
})
this.terminalInstanceOfId[terminalId] = terminal
return { terminalId, didCreateTerminal: true }
}
@ -95,37 +108,71 @@ 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)
if (!waitForCompletion) {
console.log('NOT WAITING FOR COMPLETION')
await terminal.sendText(command, true);
return { terminalId, didCreateTerminal, contents: '(command is running in background...)' };
}
let result: string = ''
let resolveReason: ResolveReason | undefined = undefined
// stream
const disposables: IDisposable[] = []
let data = ''
const d1 = terminal.onData(newData => { data += newData })
// terminal.onExit(() => {
// console.log('TERMINALEXIT')
// })
await terminal.sendText(command, true);
// wait for the command to finish
const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection);
if (commandDetection) {
const d2 = commandDetection.onCommandFinished(() => {
console.log('FINISHED', data)
d1.dispose()
d2.dispose()
return { terminalId, didCreateTerminal, contents: data }
// onFullPage
const waitUntilFullPage = new Promise<void>((res, rej) => {
const d1 = terminal.onData(async newData => {
if (resolveReason) return
result += newData
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
resolveReason = { type: 'toofull' }
res()
return
}
})
}
disposables.push(d1)
})
console.log('didnot wait', data)
d1.dispose()
return { terminalId, didCreateTerminal, contents: 'Could not await data...' }
// onDone
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(newData => {
if (resolveReason) return
const isDone = isCommandComplete(result)
if (isDone) {
resolveReason = { type: 'done', exitCode: isDone.exitCode }
res()
return
}
})
disposables.push(d2)
})
// send the command here
await terminal.sendText(command, true)
// timeout promise
const waitUntilTimeout = new Promise<void>((res, rej) => {
setTimeout(async () => {
if (resolveReason) return
await terminal.sendText('\x03', true) // interrupt the terminal with Ctrl+C
resolveReason = { type: waitForCompletion ? 'timeout' : 'bgtask' }
res()
}, (waitForCompletion ? TERMINAL_TIMEOUT_TIME : TERMINAL_BG_WAIT_TIME) * 1000)
})
await Promise.any([
waitUntilDone,
waitUntilFullPage,
waitUntilTimeout,
])
disposables.forEach(d => d.dispose())
if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.')
console.log('res', { terminalId, didCreateTerminal, result, resolveReason })
return { terminalId, didCreateTerminal, result, resolveReason }
}
@ -133,3 +180,5 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
}
registerSingleton(ITerminalToolService, TerminalToolService, InstantiationType.Delayed);

View file

@ -27,6 +27,9 @@ 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 TERMINAL_TIMEOUT_TIME = 15
export const TERMINAL_BG_WAIT_TIME = 1
@ -322,8 +325,8 @@ export class ToolsService implements IToolsService {
return {}
},
terminal_command: async ({ command, proposedTerminalId, waitForCompletion }) => {
const { terminalId, didCreateTerminal } = await this.terminalToolService.runCommand(command, proposedTerminalId, waitForCompletion)
return { terminalId, didCreateTerminal }
const { terminalId, didCreateTerminal, result, resolveReason } = await this.terminalToolService.runCommand(command, proposedTerminalId, waitForCompletion)
return { terminalId, didCreateTerminal, result, resolveReason }
},
}
@ -353,10 +356,32 @@ export class ToolsService implements IToolsService {
return `URI ${params.uri.fsPath} successfully deleted.`
},
edit: (params, result) => {
return `Change successfully made ${params.uri.fsPath} successfully deleted.`
return `Change successfully made to ${params.uri.fsPath}.`
},
terminal_command: (params, result) => {
return `Terminal command "${params.command}" successfully executed in terminal ${result.terminalId}${result.didCreateTerminal ? `(a newly-created terminal)` : ''}.`
const {
terminalId,
didCreateTerminal,
resolveReason,
result: result_,
} = result
const terminalDesc = `terminal ${terminalId}${didCreateTerminal ? ` (a newly-created terminal)` : ''}`
if (resolveReason.type === 'timeout') {
return `Terminal command ran in ${terminalDesc}, but timed out after ${TERMINAL_TIMEOUT_TIME} seconds. Result:\n${result_}`
}
else if (resolveReason.type === 'bgtask') {
return `Terminal command is running in the background in ${terminalDesc}. Here were the outputs after ${TERMINAL_BG_WAIT_TIME} seconds:\n${result_}`
}
else if (resolveReason.type === 'toofull') {
return `Terminal command executed in terminal ${terminalDesc}. Command was interrupted because output was too long. Result:\n${result_}`
}
else if (resolveReason.type === 'done') {
return `Terminal command executed in terminal ${terminalDesc}. Result (exit code ${resolveReason.exitCode}):\n${result_}`
}
throw new Error(`Unexpected internal error: Terminal command did not resolve with a valid reason.`)
},
}

View file

@ -25,6 +25,9 @@ export type ToolDirectoryItem = {
}
export type ResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number }
@ -158,6 +161,6 @@ export type ToolResultType = {
'edit': {},
'create_uri': {},
'delete_uri': {},
'terminal_command': { terminalId: string, didCreateTerminal: boolean },
'terminal_command': { terminalId: string, didCreateTerminal: boolean, result: string; resolveReason: ResolveReason; },
}

View file

@ -368,6 +368,7 @@ const prepareMessages_noEmptyMessage = ({ messages }: { messages: PrepareMessage
else if (c.type === 'tool_use') { }
else if (c.type === 'tool_result') { }
}
if (currMsg.content.length === 0) currMsg.content = [{ type: 'text', text: EMPTY_MESSAGE }]
}
}