From d6d5f77183e0f3e318654f0a8e7ef72f35d787c9 Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 11 Mar 2025 06:53:54 -0700 Subject: [PATCH] terminal progress + misc --- .../contrib/void/browser/chatThreadService.ts | 1 + .../contrib/void/browser/editCodeService.ts | 37 +++--- .../react/src/sidebar-tsx/SidebarChat.tsx | 44 +++++-- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 2 +- .../void/browser/terminalToolService.ts | 113 +++++++++++++----- .../contrib/void/browser/toolsService.ts | 33 ++++- .../contrib/void/common/toolsServiceTypes.ts | 5 +- .../llmMessage/preprocessLLMMessages.ts | 1 + 8 files changed, 163 insertions(+), 73 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 328f4fe9..77fec1ba 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -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 } diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index 7535dadc..ae16d111 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -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 diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 686a86d2..1ee518e1 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -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 <>
= { '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 }, resultWrapper: ({ toolMessage }) => { @@ -1349,19 +1352,32 @@ const toolNameToComponent: { [T in ToolName]: { return } - const { params } = toolMessage.result + + const { command } = toolMessage.result.params + const { terminalId, resolveReason, result } = toolMessage.result.value return (
-
- +
+
+ {resolveReason.type === 'bgtask' ? 'Result so far:' : null} + + { + 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 + } +
) @@ -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) => - + ) }, [previousMessages, currentThread]) @@ -1524,6 +1543,7 @@ export const SidebarChat = () => { anthropicReasoning: null, }} isLoading={isStreaming} + isLast={true} /> : null const allMessagesHTML = [...pastMessagesHTML, currStreamingMessageHTML] diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index bdf2ae0d..8da66169 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -33,7 +33,7 @@ export const SidebarThreadSelector = () => { .filter(threadId => allThreads![threadId].messages.length !== 0) return ( -
+
{/* title */} diff --git a/src/vs/workbench/contrib/void/browser/terminalToolService.ts b/src/vs/workbench/contrib/void/browser/terminalToolService.ts index 55d0e225..9d418cfe 100644 --- a/src/vs/workbench/contrib/void/browser/terminalToolService.ts +++ b/src/vs/workbench/contrib/void/browser/terminalToolService.ts @@ -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('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((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((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((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); + + diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index cc851e10..9e87bfb7 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -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.`) }, } diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index b4d00691..3c17853d 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -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; }, } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts index 6ff7906e..33c62f3d 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -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 }] } }