tool UI draft

This commit is contained in:
Mathew Pareles 2025-02-22 01:01:00 -08:00
parent 2a876d8efe
commit bdb897d032
4 changed files with 269 additions and 77 deletions

View file

@ -48,13 +48,13 @@ export type FileSelection = {
export type StagingSelectionItem = CodeSelection | FileSelection
type ToolMessage<T extends ToolName> = {
export type ToolMessage<T extends ToolName> = {
role: 'tool';
name: T; // internal use
params: string; // internal use
id: string; // apis require this tool use id
content: string; // result
result: ToolCallReturnType<T>; // text message of result
result: ToolCallReturnType[T]; // text message of result
}
@ -430,10 +430,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// 1.
let toolResult: Awaited<ReturnType<ToolFns[ToolName]>>
let toolResultVal: ToolCallReturnType<ToolName>
let toolResultVal: ToolCallReturnType[ToolName]
try {
toolResult = await this._toolsService.toolFns[toolName](tool.params)
toolResultVal = toolResult[0]
toolResultVal = toolResult
} catch (error) {
this._setStreamState(threadId, { error })
shouldSendAnotherMessage = false

View file

@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js';
import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js';
import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js';
import { BlockCode } from '../markdown/BlockCode.js';
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js';
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
import { Pencil, X } from 'lucide-react';
import { ChevronRight, Pencil, X } from 'lucide-react';
import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
import { ChatMessageLocation } from '../../../aiRegexService.js';
import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js';
@ -542,6 +543,146 @@ export const SelectedFiles = (
}
type ToolReusltToComponent = { [T in ToolName]: (props: { message: ToolMessage<T> }) => React.ReactNode }
interface ToolResultProps {
title: string;
desc: string;
desc2?: number;
children?: React.ReactNode;
}
const ToolResult = ({
title,
desc,
desc2,
children,
}: ToolResultProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isDropdown = !!children
return (
<div className="mx-4 select-none">
<div className="border border-void-border-3 rounded px-1 py-0.5 bg-void-bg-2-alt">
<div
className={`flex items-center min-h-[24px] ${isDropdown ? 'cursor-pointer hover:brightness-125 transition-all duration-150' : 'mx-1'}`}
onClick={() => children && setIsExpanded(!isExpanded)}
>
{isDropdown && (
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-5 w-5 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isExpanded ? 'rotate-90' : ''}`}
/>
)}
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5">
<span className="text-void-fg-3">{title}</span>
<span className="text-void-fg-4 text-xs italic">{`"`}{desc}{`"`}</span>
{desc2 !== undefined && (
<span className="text-void-fg-4 text-xs">
{`(`}{desc2}{` result`}{desc2 !== 1 ? 's' : ''}{`)`}
</span>
)}
</div>
</div>
<div
className={`mt-1 overflow-hidden transition-all duration-200 ease-in-out ${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}`}
>
{children}
</div>
</div>
</div>
);
};
const toolResultToComponent: ToolReusltToComponent = {
'read_file': ({ message }) => (
<ToolResult
title="Read file"
desc={getBasename(message.result.uri.fsPath)}
/>
),
'list_dir': ({ message }) => (
<ToolResult
title="Inspected folder"
desc={`${getBasename(message.result.rootURI.fsPath)}/`}
desc2={message.result.children?.length}
>
<div className="text-void-fg-2">
{message.result.children?.map((item, i) => (
<div key={i} className="pl-2">
{item.name}
{item.isDirectory && '/'}
</div>
))}
{message.result.hasNextPage && (
<div className="pl-2 text-void-fg-3 italic">
{message.result.itemsRemaining} more items...
</div>
)}
</div>
</ToolResult>
),
'pathname_search': ({ message }) => (
<ToolResult
title="Searched filename"
desc={message.result.queryStr}
desc2={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
>
<div className="text-void-fg-2">
{Array.isArray(message.result.uris) ?
message.result.uris.map((uri, i) => (
<div key={i} className="pl-2">
<a
href={uri.toString()}
className="text-void-accent hover:underline"
>
{uri.fsPath.split('/').pop()}
</a>
</div>
)) :
<div className="pl-2">{message.result.uris}</div>
}
{message.result.hasNextPage && (
<div className="pl-2 text-void-fg-3 italic">
More results available...
</div>
)}
</div>
</ToolResult>
),
'search': ({ message }) => (
<ToolResult
title="Searched"
desc={message.result.queryStr}
desc2={Array.isArray(message.result.uris) ? message.result.uris.length : 0}
>
<div className="text-void-fg-2">
{typeof message.result.uris === 'string' ?
message.result.uris :
message.result.uris.map((uri, i) => (
<div key={i} className="pl-2">
<a
href={uri.toString()}
className="text-void-accent hover:underline"
>
{uri.fsPath}
</a>
</div>
))
}
{message.result.hasNextPage && (
<div className="pl-2 text-void-fg-3 italic">
More results available...
</div>
)}
</div>
</ToolResult>
)
};
type ChatBubbleMode = 'display' | 'edit'
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => {
@ -695,7 +836,13 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
chatbubbleContents = <ChatMarkdownRender string={chatMessage.displayContent ?? ''} chatMessageLocation={chatMessageLocation} />
}
else if (role === 'tool') {
chatbubbleContents = chatMessage.name
const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here...
chatbubbleContents = <ToolComponent message={chatMessage} />
console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result)
}
return <div

View file

@ -29,16 +29,23 @@ module.exports = {
colors: {
"void-bg-1": "var(--vscode-input-background)",
"void-bg-2": "var(--vscode-sideBar-background)",
"void-bg-2-alt": "color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%)",
"void-bg-3": "var(--vscode-editor-background)",
"void-fg-1": "var(--vscode-editor-foreground)",
"void-fg-2": "var(--vscode-input-foreground)",
"void-fg-3": "var(--vscode-input-placeholderForeground)",
// "void-fg-4": "var(--vscode-tab-inactiveForeground)",
"void-fg-4": "var(--vscode-list-deemphasizedForeground)",
"void-warning": "var(--vscode-charts-yellow)",
"void-border-1": "var(--vscode-commandCenter-activeBorder)",
"void-border-2": "var(--vscode-commandCenter-border)",
"void-border-3": "var(--vscode-commandCenter-inactiveBorder)",
"void-border-3": "var(--vscode-settings-sashBorder)",
vscode: {

View file

@ -92,69 +92,93 @@ export const toolNames = Object.keys(voidTools) as ToolName[]
export type ToolParamNames<T extends ToolName> = keyof typeof voidTools[T]['params']
export type ToolParamsObj<T extends ToolName> = { [paramName in ToolParamNames<T>]: unknown }
export type ToolCallReturnType = {
'read_file': { uri: URI, fileContents: string, hasNextPage: boolean },
'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean },
'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }
'create_file': {}
}
export type ToolCallReturnType<T extends ToolName>
= T extends 'read_file' ? string
: T extends 'list_dir' ? string
: T extends 'pathname_search' ? string | URI[]
: T extends 'search' ? string | URI[]
: T extends 'create_file' ? string
: never
type DirectoryItem = {
name: string;
isDirectory: boolean;
isSymbolicLink: boolean;
}
export type ToolFns = { [T in ToolName]: (p: string) => Promise<[ToolCallReturnType<T>, boolean]> }
export type ToolResultToString = { [T in ToolName]: (result: [ToolCallReturnType<T>, boolean]) => string }
export type ToolFns = { [T in ToolName]: (p: string) => Promise<ToolCallReturnType[T]> }
export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string }
// pagination info
const MAX_FILE_CHARS_PAGE = 50_000
const MAX_CHILDREN_URIs_PAGE = 500
const MAX_DEPTH = 1
async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI, pageNumber: number): Promise<[string, boolean]> {
let output = '';
const indentation = (depth: number, isLast: boolean): string => {
if (depth === 0) return '';
return `${'| '.repeat(depth - 1)}${isLast ? '└── ' : '├── '}`;
};
let hasNextPage = false
async function traverseChildren(uri: URI, depth: number, isLast: boolean) {
const stat = await fileService.resolve(uri, { resolveMetadata: false });
// we might want to say where symlink links to
if (depth === 0 && pageNumber !== 1)
output += ''
else
output += `${indentation(depth, isLast)}${stat.name}${stat.isDirectory ? '/' : ''}${stat.isSymbolicLink ? ` (symbolic link)` : ''}\n`;
// list children
const originalChildrenLength = stat.children?.length ?? 0
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 // INCLUSIVE
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
if (!stat.isDirectory) return;
if (listChildren.length === 0) return
if (depth === MAX_DEPTH) return // right now MAX_DEPTH=1 to make pagination work nicely
for (let i = 0; i < Math.min(listChildren.length, MAX_CHILDREN_URIs_PAGE); i++) {
await traverseChildren(listChildren[i].resource, depth + 1, i === listChildren.length - 1);
}
const nCutoffResults = (originalChildrenLength - 1) - toChildIdx
if (nCutoffResults >= 1) {
output += `${indentation(depth + 1, true)}(${nCutoffResults} results remaining...)\n`
hasNextPage = true
}
const computeDirectoryResult = async (
fileService: IFileService,
rootURI: URI,
pageNumber: number = 1
): Promise<ToolCallReturnType['list_dir']> => {
const stat = await fileService.resolve(rootURI, { resolveMetadata: false });
if (!stat.isDirectory) {
return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 };
}
await traverseChildren(rootURI, 0, false);
const originalChildrenLength = stat.children?.length ?? 0;
const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1);
const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE
const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? [];
const children: DirectoryItem[] = listChildren.map(child => ({
name: child.name,
isDirectory: child.isDirectory,
isSymbolicLink: child.isSymbolicLink || false
}));
const hasNextPage = (originalChildrenLength - 1) > toChildIdx;
const hasPrevPage = pageNumber > 1;
const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1));
return {
rootURI,
children,
hasNextPage,
hasPrevPage,
itemsRemaining
};
};
const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => {
if (!result.children) {
return `Error: ${result.rootURI} is not a directory`;
}
let output = '';
const entries = result.children;
if (!result.hasPrevPage) {
output += `${result.rootURI}\n`;
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const isLast = i === entries.length - 1 && !result.hasNextPage;
const prefix = isLast ? '└── ' : '├── ';
output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`;
}
if (result.hasNextPage) {
output += `└── (${result.itemsRemaining} results remaining...)\n`;
}
return output;
};
return [output, hasNextPage]
}
const validateJSON = (s: string): { [s: string]: unknown } => {
@ -217,6 +241,8 @@ export class ToolsService implements IToolsService {
this.toolFns = {
read_file: async (s: string) => {
console.log('read_file')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
@ -227,22 +253,30 @@ export class ToolsService implements IToolsService {
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
let fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
return [fileContents || '(empty)', hasNextPage]
console.log('read_file result:', fileContents)
return { uri, fileContents, hasNextPage }
},
list_dir: async (s: string) => {
console.log('list_dir')
const o = validateJSON(s)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
const [treeStr, hasNextPage] = await generateDirectoryTreeMd(fileService, uri, pageNumber)
return [treeStr, hasNextPage]
const dirResult = await computeDirectoryResult(fileService, uri, pageNumber)
console.log('list_dir result:', dirResult)
return dirResult
},
pathname_search: async (s: string) => {
console.log('pathname_search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
@ -254,15 +288,18 @@ export class ToolsService implements IToolsService {
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const URIs = data.results
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
console.log('pathname_search result:', uris)
return [URIs, hasNextPage]
return { queryStr, uris, hasNextPage }
},
search: async (s: string) => {
console.log('search')
const o = validateJSON(s)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
@ -274,35 +311,37 @@ export class ToolsService implements IToolsService {
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const URIs = data.results
const uris = data.results
.slice(fromIdx, toIdx + 1) // paginate
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return [URIs, hasNextPage]
console.log('search result:', uris)
return { queryStr, uris, hasNextPage }
},
}
const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : ''
this.toolResultToString = {
read_file: ([fileContents, hasNextPage]) => {
return fileContents + nextPageStr(hasNextPage)
read_file: (result) => {
return nextPageStr(result.hasNextPage)
},
list_dir: ([dirTreeStr, hasNextPage]) => {
return dirTreeStr + nextPageStr(hasNextPage)
list_dir: (result) => {
const dirTreeStr = directoryResultToString(result)
return dirTreeStr + nextPageStr(result.hasNextPage)
},
pathname_search: ([URIs, hasNextPage]) => {
if (typeof URIs === 'string') return URIs
return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage)
pathname_search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search: ([URIs, hasNextPage]) => {
if (typeof URIs === 'string') return URIs
return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage)
search: (result) => {
if (typeof result.uris === 'string') return result.uris
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
}
@ -314,4 +353,3 @@ export class ToolsService implements IToolsService {
}
registerSingleton(IToolsService, ToolsService, InstantiationType.Eager);