Merge pull request #324 from voideditor/model-selection

Command Bar for Changes + Tool UI
This commit is contained in:
Andrew Pareles 2025-03-21 21:57:02 -07:00 committed by GitHub
commit 2bef719754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 5133 additions and 2825 deletions

View file

@ -1,7 +1,7 @@
{
"nameShort": "Void",
"nameLong": "Void",
"voidVersion": "1.0.3",
"voidVersion": "1.0.2",
"applicationName": "void",
"dataFolderName": ".void-editor",
"win32MutexName": "voideditor",

View file

@ -26,7 +26,7 @@
}
/* custom speed & easing for loading icon */
.codicon-loading,
.codicon-loading:not(.codicon-no-default-spin), /* Void changed this as it is literally broken to the !important */
.codicon-tree-item-loading::before {
animation-duration: 1s !important;
animation-timing-function: cubic-bezier(0.53, 0.21, 0.29, 0.67) !important;

View file

@ -3570,7 +3570,7 @@ class EditorQuickSuggestions extends BaseEditorOption<EditorOption.quickSuggesti
const defaults: InternalQuickSuggestionsOptions = {
other: 'on',
comments: 'off',
strings: 'off'
strings: 'on' // Void changed this setting
};
const types: IJSONSchema[] = [
{ type: 'boolean' },

View file

@ -27,6 +27,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js';
import { IExplorerService } from './files.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Categories } from '../../../../platform/action/common/actionCommonCategories.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../void/browser/voidSettingsPane.js';
// Contribute Global Actions
@ -675,6 +676,18 @@ for (const menuId of [MenuId.EmptyEditorGroupContext, MenuId.EditorTabsBarContex
// File menu
// Void added this:
MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, {
group: '0_void',
command: {
id: VOID_OPEN_SETTINGS_ACTION_ID,
title: nls.localize({ key: 'openVoid', comment: ['&& denotes a mnemonic'] }, "&&Open Void Settings"),
},
order: 1
});
MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, {
group: '1_new',
command: {

View file

@ -39,7 +39,7 @@ class MarkerCheckService extends Disposable implements IMarkerCheckService {
console.log(`----------------------------------------------`);
console.log(`${error.resource.toString()}: ${error.startLineNumber} ${error.message} ${error.severity}`); // ! all errors in the file
console.log(`${error.resource.fsPath}: ${error.startLineNumber} ${error.message} ${error.severity}`); // ! all errors in the file
try {
// Get the text model for the file
@ -122,11 +122,11 @@ class MarkerCheckService extends Disposable implements IMarkerCheckService {
// const markers = this._markerService.read({ resource });
// if (markers.length === 0) {
// console.log(`${resource.toString()}: No diagnostics`);
// console.log(`${resource.fsPath}: No diagnostics`);
// continue;
// }
// console.log(`Diagnostics for ${resource.toString()}:`);
// console.log(`Diagnostics for ${resource.fsPath}:`);
// markers.forEach(marker => this._logMarker(marker));
// }
// };

View file

@ -34,7 +34,7 @@
// // const result = await new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // tools: ['text_search'],
// // onFinalMessage: ({ result: r, }) => {
// // res(r)
// // },
@ -73,7 +73,7 @@
// // const result = new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // tools: ['text_search'],
// // onResult: (r) => {
// // res(r)
// // }

View file

@ -637,9 +637,12 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
token: CancellationToken,
): Promise<InlineCompletion[]> {
const isEnabled = this._settingsService.state.globalSettings.enableAutocomplete
if (!isEnabled) return []
const testMode = false
const docUriStr = model.uri.toString();
const docUriStr = model.uri.fsPath;
const prefixAndSuffix = getPrefixAndSuffixInfo(model, position)
const { prefix, suffix } = prefixAndSuffix
@ -792,10 +795,9 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName]
const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[modelSelection.providerName]?.[modelSelection.modelName] : undefined
const isEnabled = this._settingsService.state.globalSettings.enableAutocomplete
// set parameters of `newAutocompletion` appropriately
newAutocompletion.llmPromise = isEnabled ? new Promise((resolve, reject) => reject('Autocomplete is disabled')) : new Promise((resolve, reject) => {
newAutocompletion.llmPromise = new Promise((resolve, reject) => {
const requestId = this._llmMessageService.sendLLMMessage({
messagesType: 'FIMMessage',
@ -850,6 +852,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
newAutocompletion.status = 'error'
reject(message)
},
onAbort: () => { },
})
newAutocompletion.requestId = requestId
@ -913,7 +916,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
if (!resource) return;
const model = this._modelService.getModel(resource)
if (!model) return;
const docUriStr = resource.toString();
const docUriStr = resource.fsPath;
if (!this._autocompletionsOfDocument[docUriStr]) return;
const { prefix, } = getPrefixAndSuffixInfo(model, position)
@ -942,4 +945,3 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ
registerWorkbenchContribution2(AutocompleteService.ID, AutocompleteService, WorkbenchPhase.BlockRestore);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,19 +7,21 @@ import { Event } from '../../../../base/common/event.js';
import { URI } from '../../../../base/common/uri.js';
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { Diff, DiffArea } from './editCodeService.js';
export type StartBehavior = 'accept-conflicts' | 'reject-conflicts' | 'keep-conflicts'
export type StartApplyingOpts = ({
from: 'QuickEdit';
type: 'rewrite';
diffareaid: number; // id of the CtrlK area (contains text selection)
startBehavior: StartBehavior;
} | {
from: 'ClickApply';
type: 'searchReplace' | 'rewrite';
applyStr: string;
uri: 'current' | URI;
startBehavior: StartBehavior;
})
@ -30,28 +32,33 @@ export type AddCtrlKOpts = {
editor: ICodeEditor,
}
export type URIStreamState = 'idle' | 'acceptRejectAll' | 'streaming'
export const IEditCodeService = createDecorator<IEditCodeService>('editCodeService');
export interface IEditCodeService {
readonly _serviceBrand: undefined;
startApplying(opts: StartApplyingOpts): [URI, Promise<void>] | null;
startApplying(opts: StartApplyingOpts): Promise<[URI, Promise<void>] | null>;
addCtrlKZone(opts: AddCtrlKOpts): number | undefined;
removeCtrlKZone(opts: { diffareaid: number }): void;
removeDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }): void;
diffAreaOfId: Record<string, DiffArea>;
diffAreasOfURI: Record<string, Set<string> | undefined>;
diffOfId: Record<string, Diff>;
acceptOrRejectAllDiffAreas(opts: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept', _addToHistory?: boolean }): void;
// events
onDidAddOrDeleteDiffZones: Event<{ uri: URI }>;
onDidChangeDiffsInDiffZone: Event<{ uri: URI; diffareaid: number }>; // only fires when not streaming!!! streaming would be too much
onDidChangeStreamingInDiffZone: Event<{ uri: URI; diffareaid: number }>;
onDidChangeStreamingInCtrlKZone: Event<{ uri: URI; diffareaid: number }>;
// CtrlKZone streaming state
isCtrlKZoneStreaming(opts: { diffareaid: number }): boolean;
interruptCtrlKStreaming(opts: { diffareaid: number }): void;
onDidChangeCtrlKZoneStreaming: Event<{ uri: URI; diffareaid: number }>;
// // DiffZone codeBoxId streaming state
getURIStreamState(opts: { uri: URI | null }): URIStreamState;
interruptURIStreaming(opts: { uri: URI }): void;
onDidChangeURIStreamState: Event<{ uri: URI; state: URIStreamState }>;
// testDiffs(): void;
}

View file

@ -25,7 +25,7 @@ export interface IConsistentItemService {
export const IConsistentItemService = createDecorator<IConsistentItemService>('ConsistentItemService');
export class ConsistentItemService extends Disposable {
export class ConsistentItemService extends Disposable implements IConsistentItemService {
readonly _serviceBrand: undefined

View file

@ -1,52 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { URI } from '../../../../../base/common/uri.js'
import { EndOfLinePreference } from '../../../../../editor/common/model.js'
import { IModelService } from '../../../../../editor/common/services/model.js'
import { IFileService } from '../../../../../platform/files/common/files.js'
// attempts to read URI of currently opened model, then of raw file
export const VSReadFile = async (uri: URI, modelService: IModelService, fileService: IFileService) => {
const modelResult = await _VSReadModel(modelService, uri)
if (modelResult) return modelResult
const fileResult = await _VSReadFileRaw(fileService, uri)
if (fileResult) return fileResult
return ''
}
// read files from VSCode. preferred (but appears to only work if the model of this URI already exists. If it doesn't use the other function.)
const _VSReadModel = async (modelService: IModelService, uri: URI): Promise<string | null> => {
// attempt to read saved model (doesn't work if application was reloaded...)
const model = modelService.getModel(uri)
if (model) {
return model.getValue(EndOfLinePreference.LF)
}
// backup logic - look at all opened models and check if they have the same `fsPath`
const models = modelService.getModels()
for (const model of models) {
if (model.uri.fsPath === uri.fsPath)
return model.getValue(EndOfLinePreference.LF);
}
return null
}
const _VSReadFileRaw = async (fileService: IFileService, uri: URI) => {
try {
const res = await fileService.readFile(uri)
const str = res.value.toString()
return str
} catch (e) {
return null
}
}

View file

@ -83,7 +83,7 @@
.void-scrollable-element::-webkit-scrollbar,
.void-scrollable-element *::-webkit-scrollbar {
width: 14px !important;
height: 14px !important;
height: 4px !important;
}
.void-scrollable-element::-webkit-scrollbar-track,

View file

@ -0,0 +1,55 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import * as dom from '../../../../base/browser/dom.js';
import { IMetricsService } from '../common/metricsService.js';
export interface IMetricsPollService {
readonly _serviceBrand: undefined;
}
const PING_EVERY_MS = 15 * 1000 * 60 // 15 minutes
export const IMetricsPollService = createDecorator<IMetricsPollService>('voidMetricsPollService');
class MetricsPollService extends Disposable implements IMetricsPollService {
_serviceBrand: undefined;
static readonly ID = 'voidMetricsPollService';
private readonly intervalID: number
constructor(
@IMetricsService private readonly metricsService: IMetricsService,
) {
super()
// initial state
const { window } = dom.getActiveWindow()
let i = 1
this.intervalID = window.setInterval(() => {
this.metricsService.capture('Alive', { iv1: i })
i += 1
}, PING_EVERY_MS)
}
override dispose() {
super.dispose()
const { window } = dom.getActiveWindow()
window.clearInterval(this.intervalID)
}
}
registerWorkbenchContribution2(MetricsPollService.ID, MetricsPollService, WorkbenchPhase.BlockRestore);

View file

@ -1,77 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { QuickEdit } from './quickEditActions.js';
// service that manages state
export type VoidQuickEditState = {
quickEditsOfDocument: { [uri: string]: QuickEdit }
}
export interface IQuickEditStateService {
readonly _serviceBrand: undefined;
readonly state: VoidQuickEditState; // readonly to the user
setState(newState: Partial<VoidQuickEditState>): void;
onDidChangeState: Event<void>;
onDidFocusChat: Event<void>;
onDidBlurChat: Event<void>;
fireFocusChat(): void;
fireBlurChat(): void;
}
export const IQuickEditStateService = createDecorator<IQuickEditStateService>('voidQuickEditStateService');
class VoidQuickEditStateService extends Disposable implements IQuickEditStateService {
_serviceBrand: undefined;
static readonly ID = 'voidQuickEditStateService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
private readonly _onFocusChat = new Emitter<void>();
readonly onDidFocusChat: Event<void> = this._onFocusChat.event;
private readonly _onBlurChat = new Emitter<void>();
readonly onDidBlurChat: Event<void> = this._onBlurChat.event;
// state
state: VoidQuickEditState
constructor(
) {
super()
// initial state
this.state = { quickEditsOfDocument: {} }
}
setState(newState: Partial<VoidQuickEditState>) {
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
fireFocusChat() {
this._onFocusChat.fire()
}
fireBlurChat() {
this._onBlurChat.fire()
}
}
registerSingleton(IQuickEditStateService, VoidQuickEditStateService, InstantiationType.Eager);

View file

@ -1,8 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
import { useAccessor, useURIStreamState, useSettingsState } from '../util/services.js'
import { useRefState } from '../util/helpers.js'
import { useAccessor, useCommandBarState, useCommandBarURIListener, useSettingsState } from '../util/services.js'
import { usePromise, useRefState } from '../util/helpers.js'
import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { FileSymlink, LucideIcon, RotateCw } from 'lucide-react'
import { Check, X, Square, Copy, Play, } from 'lucide-react'
import { getBasename, ListableToolItem, ToolChildrenWrapper } from '../sidebar-tsx/SidebarChat.js'
import { ChatMarkdownRender } from './ChatMarkdownRender.js'
enum CopyButtonText {
Idle = 'Copy',
@ -10,6 +14,56 @@ enum CopyButtonText {
Error = 'Could not copy',
}
type IconButtonProps = {
onClick: () => void;
Icon: LucideIcon
disabled?: boolean
className?: string
}
export const IconShell1 = ({ onClick, Icon, disabled, className }: IconButtonProps) => (
<button
disabled={disabled}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
}}
className={`
size-[22px]
p-[4px]
flex items-center justify-center
text-sm bg-void-bg-3 text-void-fg-1
hover:brightness-110
border border-void-border-1 rounded
disabled:opacity-50 disabled:cursor-not-allowed
${className}
`}
>
<Icon />
</button>
)
// export const IconShell2 = ({ onClick, title, Icon, disabled, className }: IconButtonProps) => (
// <button
// title={title}
// disabled={disabled}
// onClick={onClick}
// className={`
// size-[24px]
// flex items-center justify-center
// text-sm
// hover:opacity-80
// disabled:opacity-50 disabled:cursor-not-allowed
// ${className}
// `}
// >
// <Icon size={16} />
// </button>
// )
const COPY_FEEDBACK_TIMEOUT = 1000 // amount of time to say 'Copied!'
const CopyButton = ({ codeStr }: { codeStr: string }) => {
@ -26,7 +80,6 @@ const CopyButton = ({ codeStr }: { codeStr: string }) => {
}, COPY_FEEDBACK_TIMEOUT)
}, [copyButtonText])
const onCopy = useCallback(() => {
clipboardService.writeText(codeStr)
.then(() => { setCopyButtonText(CopyButtonText.Copied) })
@ -34,115 +87,257 @@ const CopyButton = ({ codeStr }: { codeStr: string }) => {
metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only
}, [metricsService, clipboardService, codeStr, setCopyButtonText])
const isSingleLine = false //!codeStr.includes('\n')
return <button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-2 rounded`}
return <IconShell1
Icon={copyButtonText === CopyButtonText.Copied ? Check : copyButtonText === CopyButtonText.Error ? X : Copy}
onClick={onCopy}
>
{copyButtonText}
</button>
/>
}
// state persisted for duration of react only
// TODO change this to use type `ChatThreads.applyBoxState[applyBoxId]`
const applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} }
export const ApplyBlockHoverButtons = ({ codeStr, applyBoxId }: { codeStr: string, applyBoxId: string }) => {
export const JumpToFileButton = ({ uri }: { uri: URI | 'current' }) => {
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const jumpToFileButton = uri !== 'current' && (
<IconShell1
Icon={FileSymlink}
onClick={() => {
commandService.executeCommand('vscode.open', uri, { preview: true })
}}
/>
)
return jumpToFileButton
}
export const useApplyButtonHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string, applyBoxId: string, uri: URI | 'current' }) => {
const settingsState = useSettingsState()
const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const voidCommandBarService = accessor.get('IVoidCommandBarService')
const metricsService = accessor.get('IMetricsService')
const [_, rerender] = useState(0)
const applyingUri = useCallback(() => applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null, [applyBoxId])
const streamState = useCallback(() => editCodeService.getURIStreamState({ uri: applyingUri() }), [editCodeService, applyingUri])
const getUriBeingApplied = useCallback(() => {
return applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null
}, [applyBoxId])
// listen for stream updates
useURIStreamState(
useCallback((uri, newStreamState) => {
const shouldUpdate = applyingUri()?.fsPath !== uri.fsPath
if (shouldUpdate) return
rerender(c => c + 1)
}, [applyBoxId, editCodeService, applyingUri])
const getStreamState = useCallback(() => {
const uri = getUriBeingApplied()
if (!uri) return 'idle-no-changes'
return voidCommandBarService.getStreamState(uri)
}, [voidCommandBarService, getUriBeingApplied])
// listen for stream updates on this box
useCommandBarURIListener(useCallback((uri_) => {
const shouldUpdate = (
getUriBeingApplied()?.fsPath === uri_.fsPath
|| (uri !== 'current' && uri.fsPath === uri_.fsPath)
)
if (!shouldUpdate) return
rerender(c => c + 1)
}, [applyBoxId, editCodeService, getUriBeingApplied, uri])
)
const onSubmit = useCallback(() => {
const onClickSubmit = useCallback(async () => {
if (isDisabled) return
if (streamState() === 'streaming') return
const [newApplyingUri, _] = editCodeService.startApplying({
if (getStreamState() === 'streaming') return
const [newApplyingUri, applyDonePromise] = await editCodeService.startApplying({
from: 'ClickApply',
type: 'searchReplace',
applyStr: codeStr,
uri: 'current',
uri: uri,
startBehavior: 'keep-conflicts',
}) ?? []
// catch any errors by interrupting the stream
applyDonePromise?.catch(e => { if (newApplyingUri) editCodeService.interruptURIStreaming({ uri: newApplyingUri }) })
applyingURIOfApplyBoxIdRef.current[applyBoxId] = newApplyingUri ?? undefined
rerender(c => c + 1)
metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only
}, [isDisabled, streamState, editCodeService, codeStr, applyBoxId, metricsService])
}, [isDisabled, getStreamState, editCodeService, codeStr, uri, applyBoxId, metricsService])
const onInterrupt = useCallback(() => {
if (streamState() !== 'streaming') return
const uri = applyingUri()
if (getStreamState() !== 'streaming') return
const uri = getUriBeingApplied()
if (!uri) return
editCodeService.interruptURIStreaming({ uri })
metricsService.capture('Stop Apply', {})
}, [streamState, applyingUri, editCodeService, metricsService])
}, [getStreamState, getUriBeingApplied, editCodeService, metricsService])
const onAccept = useCallback(() => {
const uri = getUriBeingApplied()
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
}, [getUriBeingApplied, editCodeService])
const onReject = useCallback(() => {
const uri = getUriBeingApplied()
if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
}, [getUriBeingApplied, editCodeService])
const onReapply = useCallback(() => {
onReject()
onClickSubmit()
}, [onReject, onClickSubmit])
const currStreamState = getStreamState()
const copyButton = (
<CopyButton codeStr={codeStr} />
)
const playButton = (
<IconShell1
Icon={Play}
onClick={onClickSubmit}
/>
)
const stopButton = (
<IconShell1
Icon={Square}
onClick={onInterrupt}
/>
)
const reapplyButton = (
<IconShell1
Icon={RotateCw}
onClick={onReapply}
/>
)
const acceptButton = (
<IconShell1
Icon={Check}
onClick={onAccept}
className="text-green-600"
/>
)
const rejectButton = (
<IconShell1
Icon={X}
onClick={onReject}
className="text-red-600"
/>
)
const isSingleLine = false //!codeStr.includes('\n')
const applyButton = <button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-2 rounded`}
onClick={onSubmit}
>
Apply
</button>
let buttonsHTML = <></>
const stopButton = <button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-2 rounded`}
onClick={onInterrupt}
>
Stop
</button>
if (currStreamState === 'streaming') {
buttonsHTML = <>
<JumpToFileButton uri={uri} />
{copyButton}
{stopButton}
</>
}
const acceptRejectButtons = <>
<button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-2 rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false })
}}
>
Accept
</button>
<button
className={`${isSingleLine ? '' : 'px-1 py-0.5'} text-sm bg-void-bg-2 text-void-fg-1 hover:brightness-110 border border-void-border-2 rounded`}
onClick={() => {
const uri = applyingUri()
if (uri) editCodeService.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false })
}}
>
Reject
</button>
</>
if (currStreamState === 'idle-no-changes') {
buttonsHTML = <>
<JumpToFileButton uri={uri} />
{copyButton}
{playButton}
</>
}
if (currStreamState === 'idle-has-changes') {
buttonsHTML = <>
<JumpToFileButton uri={uri} />
{reapplyButton}
{rejectButton}
{acceptButton}
</>
}
const statusIndicatorHTML = <div className='flex flex-row items-center size-4'>
<div
className={` size-1.5 rounded-full border
${currStreamState === 'idle-no-changes' ? 'bg-void-bg-3 border-void-border-1' :
currStreamState === 'streaming' ? 'bg-orange-500 border-orange-500 shadow-[0_0_4px_0px_rgba(234,88,12,0.6)]' :
currStreamState === 'idle-has-changes' ? 'bg-green-500 border-green-500 shadow-[0_0_4px_0px_rgba(22,163,74,0.6)]' :
'bg-void-border-1 border-void-border-1'
}`
}
/>
</div>
return {
statusIndicatorHTML,
buttonsHTML,
}
}
export const BlockCodeApplyWrapper = ({
children,
initValue,
applyBoxId,
language,
canApply,
uri,
}: {
initValue: string;
children: React.ReactNode;
applyBoxId: string;
canApply: boolean;
language: string;
uri: URI | 'current',
}) => {
const { statusIndicatorHTML, buttonsHTML } = useApplyButtonHTML({ codeStr: initValue, applyBoxId, uri })
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
const name = uri !== 'current' ?
<ListableToolItem
name={<span className='not-italic'>{getBasename(uri.fsPath)}</span>}
isSmall={true}
showDot={false}
onClick={() => { commandService.executeCommand('vscode.open', uri, { preview: true }) }}
/>
: <span>{language}</span>
return <div className='border border-void-border-3 rounded overflow-hidden bg-void-bg-3 my-1'>
{/* header */}
<div className=" select-none flex justify-between items-center py-1 px-2 border-b border-void-border-3 cursor-default">
<div className="flex items-center">
{statusIndicatorHTML}
<span className="text-[13px] font-light text-void-fg-3">
{name}
</span>
</div>
<div className={`${canApply ? '' : 'hidden'} flex items-center gap-1`}>
{buttonsHTML}
</div>
</div>
{/* contents */}
<ToolChildrenWrapper>
{children}
</ToolChildrenWrapper>
</div>
const currStreamState = streamState()
return <>
{currStreamState !== 'streaming' && <CopyButton codeStr={codeStr} />}
{currStreamState === 'idle' && !isDisabled && applyButton}
{currStreamState === 'streaming' && stopButton}
{currStreamState === 'acceptRejectAll' && acceptRejectButtons}
</>
}

View file

@ -1,29 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React from 'react';
import { VoidCodeEditor, VoidCodeEditorProps } from '../util/inputs.js';
export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHover?: React.ReactNode } & VoidCodeEditorProps) => {
const isSingleLine = !codeEditorProps.initValue.includes('\n')
return (
<>
<div className="relative group w-full overflow-hidden">
{buttonsOnHover === null ? null : (
<div className={`z-[1] absolute top-0 right-0 opacity-0 group-hover:opacity-100 duration-200 ${isSingleLine ? 'h-full flex items-center' : ''}`}>
<div className={`flex space-x-1 ${isSingleLine ? 'pr-2' : 'p-2'}`}>
{buttonsOnHover}
</div>
</div>
)}
<VoidCodeEditor {...codeEditorProps} />
</div>
</>
)
}

View file

@ -3,15 +3,17 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { JSX, useState } from 'react'
import React, { JSX, useMemo, useState } from 'react'
import { marked, MarkedToken, Token } from 'marked'
import { BlockCode } from './BlockCode.js'
import { nameToVscodeLanguage } from '../../../../common/helpers/detectLanguage.js'
import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js'
import { useAccessor, useChatThreadsState } from '../util/services.js'
import { Range } from '../../../../../../services/search/common/searchExtTypes.js'
import { IRange } from '../../../../../../../base/common/range.js'
import { convertToVscodeLang, detectLanguage } from '../../../../common/helpers/languageHelpers.js'
import { BlockCodeApplyWrapper } from './ApplyBlockHoverButtons.js'
import { useAccessor } from '../util/services.js'
import { ScrollType } from '../../../../../../../editor/common/editorCommon.js'
import { URI } from '../../../../../../../base/common/uri.js'
import { isAbsolute } from '../../../../../../../base/common/path.js'
import { separateOutFirstLine } from '../../../../common/helpers/util.js'
import { BlockCode } from '../util/inputs.js'
export type ChatMessageLocation = {
@ -21,13 +23,18 @@ export type ChatMessageLocation = {
type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string }
const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => {
export const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => {
return `${threadId}-${messageIdx}-${tokenIdx}`
}
function isValidUri(s: string): boolean {
return s.length > 5 && isAbsolute(s) && !s.includes('//') && !s.includes('/*') // common case that is a false positive is comments like //
}
const Codespan = ({ text, className, onClick }: { text: string, className?: string, onClick?: () => void }) => {
// TODO compute this once for efficiency. we should use `labels.ts/shorten` to display duplicates properly
return <code
className={`font-mono font-medium rounded-sm bg-void-bg-1 px-1 ${className}`}
onClick={onClick}
@ -42,7 +49,7 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
const accessor = useAccessor()
const chatThreadService = accessor.get('IChatThreadService')
const commandSerivce = accessor.get('ICommandService')
const commandService = accessor.get('ICommandService')
const editorService = accessor.get('ICodeEditorService')
const { messageIdx, threadId } = chatMessageLocation
@ -56,8 +63,8 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
link = chatThreadService.getCodespanLink({ codespanStr: text, messageIdx, threadId })
if (link === undefined) {
// generate link and add to cache
(chatThreadService.generateCodespanLink(text)
// if no link, generate link and add to cache
(chatThreadService.generateCodespanLink({ codespanStr: text, threadId })
.then(link => {
chatThreadService.addCodespanLink({ newLinkText: text, newLinkLocation: link, messageIdx, threadId })
setDidComputeCodespanLink(true) // rerender
@ -74,7 +81,7 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
const selection = link.selection
// open the file
commandSerivce.executeCommand('vscode.open', link.uri).then(() => {
commandService.executeCommand('vscode.open', link.uri).then(() => {
// select the text
setTimeout(() => {
@ -93,19 +100,24 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
}
return <Codespan
text={text}
// text={link?.displayText || text}
text={link?.displayText || text}
onClick={onClick}
className={link ? 'underline hover:brightness-90 transition-all duration-200 cursor-pointer' : ''}
/>
}
const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
export type RenderTokenOptions = { isApplyEnabled?: boolean, isLinkDetectionEnabled?: boolean }
const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ...options }: { token: Token | string, inPTag?: boolean, codeURI?: URI, chatMessageLocation?: ChatMessageLocation, tokenIdx: string, } & RenderTokenOptions): React.ReactNode => {
const accessor = useAccessor()
const languageService = accessor.get('ILanguageService')
// deal with built-in tokens first (assume marked token)
const t = token as MarkedToken
if (t.raw.trim() === '') {
return <></>;
return null;
}
if (t.type === "space") {
@ -113,29 +125,67 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
}
if (t.type === "code") {
const [firstLine, remainingContents] = separateOutFirstLine(t.text)
const firstLineIsURI = isValidUri(firstLine) && !codeURI
const contents = firstLineIsURI ? (remainingContents?.trimStart() || '') : t.text // exclude first-line URI from contents
const applyBoxId = chatMessageLocation ? getApplyBoxId({
threadId: chatMessageLocation.threadId,
messageIdx: chatMessageLocation.messageIdx,
tokenIdx: tokenIdx,
}) : null
if (!contents) return null
// TODO user should only be able to apply this when the code has been closed (t.raw ends with "```")
// figure out langauge and URI
let uri: URI | null
let language: string
if (codeURI) {
uri = codeURI
}
else if (firstLineIsURI) { // get lang from the uri in the first line of the markdown
uri = URI.file(firstLine)
}
else {
uri = null
}
return <div>
<BlockCode
initValue={t.text}
language={t.lang === undefined ? undefined : nameToVscodeLanguage[t.lang]}
buttonsOnHover={applyBoxId && <ApplyBlockHoverButtons applyBoxId={applyBoxId} codeStr={t.text} />}
/>
</div>
if (t.lang) { // a language was provided. empty string is common so check truthy, not just undefined
language = convertToVscodeLang(languageService, t.lang) // convert markdown language to language that vscode recognizes (eg markdown doesn't know bash but it does know shell)
}
else { // no language provided - fallback - get lang from the uri and contents
language = detectLanguage(languageService, { uri, fileContents: contents })
}
if (options.isApplyEnabled && chatMessageLocation) {
const isCodeblockClosed = t.raw.trimEnd().endsWith('```') // user should only be able to Apply when the code has been closed (t.raw ends with "```")
const applyBoxId = getApplyBoxId({
threadId: chatMessageLocation.threadId,
messageIdx: chatMessageLocation.messageIdx,
tokenIdx: tokenIdx,
})
return <BlockCodeApplyWrapper
canApply={isCodeblockClosed}
applyBoxId={applyBoxId}
initValue={contents}
language={language}
uri={uri || 'current'}
>
<BlockCode
initValue={contents}
language={language}
/>
</BlockCodeApplyWrapper>
}
return <BlockCode
initValue={contents}
language={language}
/>
}
if (t.type === "heading") {
const HeadingTag = `h${t.depth}` as keyof JSX.IntrinsicElements
return <HeadingTag>{t.text}</HeadingTag>
return <HeadingTag>
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={t.text} inPTag={true} codeURI={codeURI} {...options} />
</HeadingTag>
}
if (t.type === "table") {
@ -213,7 +263,7 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
return <li>
<input type="checkbox" checked={t.checked} readOnly />
<span>
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={t.text} nested={true} />
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={t.text} inPTag={true} codeURI={codeURI} {...options} />
</span>
</li>
}
@ -229,7 +279,7 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
<input type="checkbox" checked={item.checked} readOnly />
)}
<span>
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} nested={true} />
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} inPTag={true} {...options} />
</span>
</li>
))}
@ -242,25 +292,22 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
{t.tokens.map((token, index) => (
<RenderToken key={index}
token={token}
tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} // assign a unique tokenId to nested components
tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} // assign a unique tokenId to inPTag components
chatMessageLocation={chatMessageLocation}
inPTag={true}
{...options}
/>
))}
</>
if (nested) return contents
return <p>
{contents}
</p>
if (inPTag) return <span className='block'>{contents}</span>
return <p>{contents}</p>
}
if (t.type === "html") {
return (
<p>
{t.raw}
</p>
)
const contents = t.raw
if (inPTag) return <span className='block'>{contents}</span>
return <p>{contents}</p>
}
if (t.type === "text" || t.type === "escape") {
@ -304,12 +351,13 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
// inline code
if (t.type === "codespan") {
if (chatMessageLocation) {
if (options.isLinkDetectionEnabled && chatMessageLocation) {
return <CodespanWithLink
text={t.text}
rawText={t.raw}
chatMessageLocation={chatMessageLocation}
/>
}
return <Codespan text={t.text} />
@ -331,12 +379,13 @@ const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token:
)
}
export const ChatMarkdownRender = ({ string, nested = false, chatMessageLocation }: { string: string, nested?: boolean, chatMessageLocation: ChatMessageLocation | undefined }) => {
export const ChatMarkdownRender = ({ string, inPTag = false, chatMessageLocation, ...options }: { string: string, inPTag?: boolean, codeURI?: URI, chatMessageLocation: ChatMessageLocation | undefined } & RenderTokenOptions) => {
const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer
return (
<>
{tokens.map((token, index) => (
<RenderToken key={index} token={token} nested={nested} chatMessageLocation={chatMessageLocation} tokenIdx={index + ''} />
<RenderToken key={index} token={token} inPTag={inPTag} chatMessageLocation={chatMessageLocation} tokenIdx={index + ''} {...options} />
))}
</>
)

View file

@ -3,12 +3,11 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSettingsState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js';
import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js';
import { QuickEditPropsType } from '../../../quickEditActions.js';
import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js';
import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js';
import { VOID_CTRL_K_ACTION_ID } from '../../../actionIDs.js';
import { useRefState } from '../util/helpers.js';
import { useScrollbarStyles } from '../util/useScrollbarStyles.js';
@ -55,17 +54,24 @@ export const QuickEditChat = ({
setIsStreamingRef(isStreaming)
}, [diffareaid, setIsStreamingRef]))
const loadingIcon = <div
className="@@codicon @@codicon-loading @@codicon-modifier-spin @@codicon-no-default-spin text-void-fg-3"
/>
const onSubmit = useCallback(() => {
const onSubmit = useCallback(async () => {
if (isDisabled) return
if (isStreamingRef.current) return
textAreaFnsRef.current?.disable()
editCodeService.startApplying({
const [newApplyingUri, applyDonePromise] = await editCodeService.startApplying({
from: 'QuickEdit',
type: 'rewrite',
diffareaid,
})
startBehavior: 'keep-conflicts',
}) ?? []
// catch any errors by interrupting the stream
applyDonePromise?.catch(e => { if (newApplyingUri) editCodeService.interruptCtrlKStreaming({ diffareaid }) })
}, [isStreamingRef, isDisabled, editCodeService, diffareaid])
const onInterrupt = useCallback(() => {
@ -87,13 +93,14 @@ export const QuickEditChat = ({
const chatAreaRef = useRef<HTMLDivElement | null>(null)
return <div ref={sizerRef} style={{ maxWidth: 450 }} className={`py-2 w-full`}>
<VoidChatArea
featureName='Ctrl+K'
divRef={chatAreaRef}
onSubmit={onSubmit}
onAbort={onInterrupt}
onClose={onX}
isStreaming={isStreamingRef.current}
loadingIcon={loadingIcon}
isDisabled={isDisabled}
className="py-2 w-full"
onClickAnywhere={() => { textAreaRef.current?.focus() }}
>
<VoidInputBox2

View file

@ -29,11 +29,11 @@ export const SidebarThreadSelector = () => {
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => allThreads![threadId1].lastModified > allThreads![threadId2].lastModified ? -1 : 1)
.filter(threadId => allThreads![threadId].messages.length !== 0)
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 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 */}
@ -63,12 +63,16 @@ export const SidebarThreadSelector = () => {
if (!allThreads) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
const pastThread = allThreads[threadId];
if (!pastThread) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request');
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
@ -102,7 +106,7 @@ export const SidebarThreadSelector = () => {
`}
onClick={() => chatThreadsService.switchToThread(pastThread.id)}
onDoubleClick={() => sidebarStateService.setState({ isHistoryOpen: false })}
title={new Date(pastThread.createdAt).toLocaleString()}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -17,3 +17,12 @@ export const useRefState = <T,>(initVal: T): ReturnType<T> => {
}, [])
return [ref, setState]
}
export const usePromise = <T,>(promise: Promise<T>): T | undefined => {
const [val, setVal] = useState<T | undefined>(undefined)
useEffect(() => {
promise.then((v) => setVal(v))
}, [promise])
return val
}

View file

@ -316,18 +316,18 @@ export const VoidSlider = ({
{/* Track */}
<div
className={`relative ${size === 'xxs' ? 'h-0.5' :
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
} bg-gray-200 dark:bg-gray-700 rounded-full cursor-pointer`}
onClick={handleTrackClick}
>
{/* Filled part of track */}
<div
className={`absolute left-0 ${size === 'xxs' ? 'h-0.5' :
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
size === 'xs' ? 'h-1' :
size === 'sm' ? 'h-1.5' :
size === 'sm+' ? 'h-2' : 'h-2.5'
} bg-gray-900 dark:bg-white rounded-full`}
style={{ width: `${percentage}%` }}
/>
@ -460,7 +460,7 @@ export const VoidCheckBox = ({ label, value, onClick, className }: { label: stri
export const VoidCustomDropdownBox = <T extends any>({
export const VoidCustomDropdownBox = <T extends NonNullable<any>>({
options,
selectedOption,
onChangeOption,
@ -471,7 +471,8 @@ export const VoidCustomDropdownBox = <T extends any>({
className,
arrowTouchesText = true,
matchInputWidth = false,
gap = 0,
gapPx = 0,
offsetPx = -6,
}: {
options: T[];
selectedOption: T | undefined;
@ -483,7 +484,8 @@ export const VoidCustomDropdownBox = <T extends any>({
className?: string;
arrowTouchesText?: boolean;
matchInputWidth?: boolean;
gap?: number;
gapPx?: number;
offsetPx?: number;
}) => {
const [isOpen, setIsOpen] = useState(false);
const measureRef = useRef<HTMLDivElement>(null);
@ -502,7 +504,7 @@ export const VoidCustomDropdownBox = <T extends any>({
placement: 'bottom-start',
middleware: [
offset(gap),
offset({ mainAxis: gapPx, crossAxis: offsetPx }),
flip({
boundary: document.body,
padding: 8
@ -537,7 +539,7 @@ export const VoidCustomDropdownBox = <T extends any>({
// if the selected option is null, set the selection to the 0th option
useEffect(() => {
if (options.length === 0) return
if (selectedOption) return
if (selectedOption !== undefined) return
onChangeOption(options[0])
}, [selectedOption, onChangeOption, options])
@ -566,7 +568,7 @@ export const VoidCustomDropdownBox = <T extends any>({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, refs.floating, refs.reference]);
if (!selectedOption)
if (selectedOption === undefined)
return null
return (
@ -785,8 +787,8 @@ const normalizeIndentation = (code: string): string => {
const modelOfEditorId: { [id: string]: ITextModel | undefined } = {}
export type VoidCodeEditorProps = { initValue: string, language?: string, maxHeight?: number, showScrollbars?: boolean }
export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars }: VoidCodeEditorProps) => {
export type BlockCodeProps = { initValue: string, language?: string, maxHeight?: number, showScrollbars?: boolean }
export const BlockCode = ({ initValue, language, maxHeight, showScrollbars }: BlockCodeProps) => {
initValue = normalizeIndentation(initValue)
@ -801,7 +803,6 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars
// const languageDetectionService = accessor.get('ILanguageDetectionService')
const modelService = accessor.get('IModelService')
const id = useId()
// these are used to pass to the model creation of modelRef
@ -882,9 +883,11 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars
}, [instantiationService])}
onCreateInstance={useCallback((editor: CodeEditorWidget) => {
const languageId = languageRef.current ? languageRef.current : 'plaintext'
const model = modelOfEditorId[id] ?? modelService.createModel(
initValueRef.current + '\n', {
languageId: languageRef.current ? languageRef.current : 'typescript',
initValueRef.current, {
languageId: languageId,
onDidChange: (e) => { return { dispose: () => { } } } // no idea why they'd require this
})
modelRef.current = model
@ -921,7 +924,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars
export const VoidButton = ({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) => {
return <button disabled={disabled}
className='px-3 py-1 bg-black/10 dark:bg-gray-200/10 rounded-sm overflow-hidden'
className='px-3 py-1 bg-black/10 dark:bg-gray-200/10 rounded-sm overflow-hidden whitespace-nowrap'
onClick={onClick}
>{children}</button>
}

View file

@ -18,9 +18,21 @@ export const mountFnGenerator = (Component: (params: any) => React.ReactNode) =>
const disposables = _registerServices(accessor)
const root = ReactDOM.createRoot(rootElement)
root.render(<Component {...props} />); // tailwind dark theme indicator
return disposables
const rerender = (props?: any) => {
root.render(<Component {...props} />); // tailwind dark theme indicator
}
const dispose = () => {
root.unmount();
disposables.forEach(d => d.dispose());
}
rerender(props)
const returnVal = {
rerender,
dispose,
}
return returnVal
}

View file

@ -9,8 +9,6 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
import { VoidSettingsState } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'
import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js'
import { VoidUriState } from '../../../voidUriStateService.js';
import { VoidQuickEditState } from '../../../quickEditStateService.js'
import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'
import { ServicesAccessor } from '../../../../../../../editor/browser/editorExtensions.js';
@ -24,10 +22,8 @@ import { IThemeService } from '../../../../../../../platform/theme/common/themeS
import { ILLMMessageService } from '../../../../common/sendLLMMessageService.js';
import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js';
import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js';
import { IEditCodeService, URIStreamState } from '../../../editCodeServiceInterface.js'
import { IEditCodeService } from '../../../editCodeServiceInterface.js'
import { IVoidUriStateService } from '../../../voidUriStateService.js';
import { IQuickEditStateService } from '../../../quickEditStateService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'
@ -45,18 +41,17 @@ 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'
import { ILanguageService } from '../../../../../../../editor/common/languages/language.js'
import { IVoidModelService } from '../../../../common/voidModelService.js'
import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js'
import { IVoidCommandBarService } from '../../../voidCommandBarService.js'
// normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes
// even if React hasn't mounted yet, the variables are always updated to the latest state.
// React listens by adding a setState function to these listeners.
let uriState: VoidUriState
const uriStateListeners: Set<(s: VoidUriState) => void> = new Set()
let quickEditState: VoidQuickEditState
const quickEditStateListeners: Set<(s: VoidQuickEditState) => void> = new Set()
let sidebarState: VoidSidebarState
const sidebarStateListeners: Set<(s: VoidSidebarState) => void> = new Set()
@ -78,54 +73,30 @@ let colorThemeState: ColorScheme
const colorThemeStateListeners: Set<(s: ColorScheme) => void> = new Set()
const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => void> = new Set()
const uriStreamingStateListeners: Set<(uri: URI, s: URIStreamState) => void> = new Set()
const commandBarURIStateListeners: Set<(uri: URI) => void> = new Set();
const activeURIListeners: Set<(uri: URI | null) => void> = new Set();
// must call this before you can use any of the hooks below
// this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it!
let wasCalled = false
export const _registerServices = (accessor: ServicesAccessor) => {
const disposables: IDisposable[] = []
// don't register services twice
if (wasCalled) {
return
// console.error(`⚠️ Void _registerServices was called again! It should only be called once.`)
}
wasCalled = true
_registerAccessor(accessor)
const stateServices = {
uriStateService: accessor.get(IVoidUriStateService),
quickEditStateService: accessor.get(IQuickEditStateService),
sidebarStateService: accessor.get(ISidebarStateService),
chatThreadsStateService: accessor.get(IChatThreadService),
settingsStateService: accessor.get(IVoidSettingsService),
refreshModelService: accessor.get(IRefreshModelService),
themeService: accessor.get(IThemeService),
editCodeService: accessor.get(IEditCodeService),
voidCommandBarService: accessor.get(IVoidCommandBarService),
modelService: accessor.get(IModelService),
}
const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService } = stateServices
uriState = uriStateService.state
disposables.push(
uriStateService.onDidChangeState(() => {
uriState = uriStateService.state
uriStateListeners.forEach(l => l(uriState))
})
)
quickEditState = quickEditStateService.state
disposables.push(
quickEditStateService.onDidChangeState(() => {
quickEditState = quickEditStateService.state
quickEditStateListeners.forEach(l => l(quickEditState))
})
)
const { sidebarStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices
sidebarState = sidebarStateService.state
disposables.push(
@ -179,15 +150,21 @@ export const _registerServices = (accessor: ServicesAccessor) => {
// no state
disposables.push(
editCodeService.onDidChangeCtrlKZoneStreaming(({ diffareaid }) => {
editCodeService.onDidChangeStreamingInCtrlKZone(({ diffareaid }) => {
const isStreaming = editCodeService.isCtrlKZoneStreaming({ diffareaid })
ctrlKZoneStreamingStateListeners.forEach(l => l(diffareaid, isStreaming))
})
)
disposables.push(
editCodeService.onDidChangeURIStreamState(({ uri }) => {
const isStreaming = editCodeService.getURIStreamState({ uri })
uriStreamingStateListeners.forEach(l => l(uri, isStreaming))
voidCommandBarService.onDidChangeState(({ uri }) => {
commandBarURIStateListeners.forEach(l => l(uri));
})
)
disposables.push(
voidCommandBarService.onDidChangeActiveURI(({ uri }) => {
activeURIListeners.forEach(l => l(uri));
})
)
@ -211,8 +188,6 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IRefreshModelService: accessor.get(IRefreshModelService),
IVoidSettingsService: accessor.get(IVoidSettingsService),
IEditCodeService: accessor.get(IEditCodeService),
IVoidUriStateService: accessor.get(IVoidUriStateService),
IQuickEditStateService: accessor.get(IQuickEditStateService),
ISidebarStateService: accessor.get(ISidebarStateService),
IChatThreadService: accessor.get(IChatThreadService),
@ -232,6 +207,12 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IConfigurationService: accessor.get(IConfigurationService),
IPathService: accessor.get(IPathService),
IMetricsService: accessor.get(IMetricsService),
ITerminalToolService: accessor.get(ITerminalToolService),
ILanguageService: accessor.get(ILanguageService),
IVoidModelService: accessor.get(IVoidModelService),
IWorkspaceContextService: accessor.get(IWorkspaceContextService),
IVoidCommandBarService: accessor.get(IVoidCommandBarService),
} as const
return reactAccessor
@ -259,26 +240,6 @@ export const useAccessor = () => {
// -- state of services --
export const useUriState = () => {
const [s, ss] = useState(uriState)
useEffect(() => {
ss(uriState)
uriStateListeners.add(ss)
return () => { uriStateListeners.delete(ss) }
}, [ss])
return s
}
export const useQuickEditState = () => {
const [s, ss] = useState(quickEditState)
useEffect(() => {
ss(quickEditState)
quickEditStateListeners.add(ss)
return () => { quickEditStateListeners.delete(ss) }
}, [ss])
return s
}
export const useSidebarState = () => {
const [s, ss] = useState(sidebarState)
useEffect(() => {
@ -365,14 +326,6 @@ export const useCtrlKZoneStreamingState = (listener: (diffareaid: number, s: boo
}, [listener, ctrlKZoneStreamingStateListeners])
}
export const useURIStreamState = (listener: (uri: URI, s: URIStreamState) => void) => {
useEffect(() => {
uriStreamingStateListeners.add(listener)
return () => { uriStreamingStateListeners.delete(listener) }
}, [listener, uriStreamingStateListeners])
}
export const useIsDark = () => {
const [s, ss] = useState(colorThemeState)
useEffect(() => {
@ -384,6 +337,40 @@ export const useIsDark = () => {
// s is the theme, return isDark instead of s
const isDark = s === ColorScheme.DARK || s === ColorScheme.HIGH_CONTRAST_DARK
return isDark
}
export const useCommandBarURIListener = (listener: (uri: URI) => void) => {
useEffect(() => {
commandBarURIStateListeners.add(listener);
return () => { commandBarURIStateListeners.delete(listener) };
}, [listener]);
};
export const useCommandBarState = () => {
const accessor = useAccessor()
const commandBarService = accessor.get('IVoidCommandBarService')
const [s, ss] = useState({ state: commandBarService.stateOfURI, sortedURIs: commandBarService.sortedURIs });
const listener = useCallback(() => {
ss({ state: commandBarService.stateOfURI, sortedURIs: commandBarService.sortedURIs });
}, [commandBarService])
useCommandBarURIListener(listener)
return s;
}
// roughly gets the active URI - this is used to get the history of recent URIs
export const useActiveURI = () => {
const accessor = useAccessor()
const commandBarService = accessor.get('IVoidCommandBarService')
const [s, ss] = useState(commandBarService.activeURI)
useEffect(() => {
const listener = () => { ss(commandBarService.activeURI) }
activeURIListeners.add(listener);
return () => { activeURIListeners.delete(listener) };
}, [])
return { uri: s }
}

View file

@ -0,0 +1,307 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { useAccessor, useCommandBarState, useIsDark } from '../util/services.js';
import '../styles.css'
import { useCallback, useEffect, useState, useRef } from 'react';
import { ScrollType } from '../../../../../../../editor/common/editorCommon.js';
import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBorder } from '../../../../common/helpers/colors.js';
import { VoidCommandBarProps } from '../../../voidCommandBarService.js';
export const VoidCommandBarMain = ({ uri, editor }: VoidCommandBarProps) => {
const isDark = useIsDark()
return <div
className={`@@void-scope ${isDark ? 'dark' : ''}`}
>
<VoidCommandBar uri={uri} editor={editor} />
</div>
}
const stepIdx = (currIdx: number | null, len: number, step: -1 | 1) => {
if (len === 0) return null
return ((currIdx ?? 0) + step + len) % len // for some reason, small negatives are kept negative. just add len to offset
}
const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const editorService = accessor.get('ICodeEditorService')
const metricsService = accessor.get('IMetricsService')
const commandService = accessor.get('ICommandService')
const commandBarService = accessor.get('IVoidCommandBarService')
const voidModelService = accessor.get('IVoidModelService')
const { state: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState()
// useEffect(() => {
// console.log('MOUNTING!!!')
// }, [])
// latestUriIdx is used to remember place in leftRight
const _latestValidUriIdxRef = useRef<number | null>(null)
// i is the current index of the URI in sortedCommandBarURIs
const i_ = sortedCommandBarURIs.findIndex(e => e.fsPath === uri?.fsPath)
const currFileIdx = i_ === -1 ? null : i_
useEffect(() => {
if (currFileIdx !== null) _latestValidUriIdxRef.current = currFileIdx
}, [currFileIdx])
const uriIdxInStepper = currFileIdx !== null ? currFileIdx // use currFileIdx if it exists, else use latestNotNullUriIdxRef
: _latestValidUriIdxRef.current === null ? null
: _latestValidUriIdxRef.current < sortedCommandBarURIs.length ? _latestValidUriIdxRef.current
: null
// when change URI, scroll to the proper spot
useEffect(() => {
setTimeout(() => {
// check undefined
if (!uri) return
const s = commandBarService.stateOfURI[uri.fsPath]
if (!s) return
const { diffIdx } = s
goToDiffIdx(diffIdx ?? 0)
}, 50)
}, [uri, commandBarService])
if (uri?.scheme !== 'file') return null // don't show in editors that we made, they must be files
const getNextDiffIdx = (step: 1 | -1) => {
// check undefined
if (!uri) return null
const s = commandBarState[uri.fsPath]
if (!s) return null
const { diffIdx, sortedDiffIds } = s
// get next idx
const nextDiffIdx = stepIdx(diffIdx, sortedDiffIds.length, step)
return nextDiffIdx
}
const goToDiffIdx = (idx: number | null) => {
if (idx === null) return
// check undefined
if (!uri) return
const s = commandBarState[uri.fsPath]
if (!s) return
const { sortedDiffIds } = s
// reveal
const diffid = sortedDiffIds[idx]
if (diffid === undefined) return
const diff = editCodeService.diffOfId[diffid]
if (!diff) return
editor.revealLineNearTop(diff.startLine, ScrollType.Immediate)
commandBarService.setDiffIdx(uri, idx)
}
const getNextUriIdx = (step: 1 | -1) => {
return stepIdx(uriIdxInStepper, sortedCommandBarURIs.length, step)
}
const goToURIIdx = async (idx: number | null) => {
if (idx === null) return
const nextURI = sortedCommandBarURIs[idx]
editCodeService.diffAreasOfURI
const { model } = await voidModelService.getModelSafe(nextURI)
if (model) {
// switch to the URI
editorService.openCodeEditor({ resource: nextURI, options: { revealIfVisible: true } }, editor)
}
}
const currDiffIdx = uri ? commandBarState[uri.fsPath]?.diffIdx ?? null : null
const sortedDiffIds = uri ? commandBarState[uri.fsPath]?.sortedDiffIds ?? [] : []
const sortedDiffZoneIds = uri ? commandBarState[uri.fsPath]?.sortedDiffZoneIds ?? [] : []
const isADiffInThisFile = sortedDiffIds.length !== 0
const isADiffZoneInThisFile = sortedDiffZoneIds.length !== 0
const isADiffZoneInAnyFile = sortedCommandBarURIs.length !== 0
const streamState = uri ? commandBarService.getStreamState(uri) : null
const showAcceptRejectAll = streamState === 'idle-has-changes'
const nextDiffIdx = getNextDiffIdx(1)
const prevDiffIdx = getNextDiffIdx(-1)
const nextURIIdx = getNextUriIdx(1)
const prevURIIdx = getNextUriIdx(-1)
const upDownDisabled = prevDiffIdx === null || nextDiffIdx === null
const leftRightDisabled = prevURIIdx === null || nextURIIdx === null // || (sortedCommandBarURIs.length === 1 && isADiffZoneInThisFile)
const upButton = <button
className={`
size-6 rounded cursor-default
hover:bg-void-bg-1-alt
`}// --border border-void-border-3 focus:border-void-border-1
disabled={upDownDisabled}
onClick={() => { goToDiffIdx(prevDiffIdx) }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToDiffIdx(prevDiffIdx);
}
}}
></button>
const downButton = <button
className={`
size-6 rounded cursor-default
hover:bg-void-bg-1-alt
`}
disabled={upDownDisabled}
onClick={() => { goToDiffIdx(nextDiffIdx) }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToDiffIdx(nextDiffIdx);
}
}}
></button>
const leftButton = <button
className={`
size-6 rounded cursor-default
hover:bg-void-bg-1-alt
`}
disabled={leftRightDisabled}
onClick={() => goToURIIdx(prevURIIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToURIIdx(prevURIIdx);
}
}}
></button>
const rightButton = <button
className={`
size-6 rounded cursor-default
hover:bg-void-bg-1-alt
`}
disabled={leftRightDisabled}
onClick={() => goToURIIdx(nextURIIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToURIIdx(nextURIIdx);
}
}}
></button>
// accept/reject if current URI has changes
const onAcceptAll = () => {
if (!uri) return
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false, _addToHistory: true })
metricsService.capture('Accept All', {})
}
const onRejectAll = () => {
if (!uri) return
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false, _addToHistory: true })
metricsService.capture('Reject All', {})
}
if (!isADiffZoneInAnyFile) return null
const acceptAllButton = <button
className='text-nowrap'
onClick={onAcceptAll}
style={{
backgroundColor: acceptAllBg,
border: acceptBorder,
color: buttonTextColor,
fontSize: buttonFontSize,
padding: '2px 4px',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Accept File
</button>
const rejectAllButton = <button
className='text-nowrap'
onClick={onRejectAll}
style={{
backgroundColor: rejectAllBg,
border: rejectBorder,
color: 'white',
fontSize: buttonFontSize,
padding: '2px 4px',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Reject File
</button>
const acceptRejectAllButtons = <div className="flex items-center gap-1 text-sm">
{acceptAllButton}
{rejectAllButton}
</div>
// const closeCommandBar = useCallback(() => {
// commandService.executeCommand('void.hideCommandBar');
// }, [commandService]);
// const hideButton = <button
// className='ml-auto pointer-events-auto'
// onClick={closeCommandBar}
// style={{
// color: buttonTextColor,
// fontSize: buttonFontSize,
// padding: '2px 4px',
// borderRadius: '6px',
// cursor: 'pointer'
// }}
// title="Close command bar"
// >x
// </button>
const leftRightUpDownButtons = <div className='p-1 gap-1 flex flex-col items-center bg-void-bg-2 rounded shadow-md border border-void-border-2 w-full'>
<div className="flex flex-col gap-1">
{/* Changes in file */}
<div className={`${!isADiffZoneInThisFile ? 'hidden' : ''} flex items-center ${upDownDisabled ? 'opacity-50' : ''}`}>
{downButton}
{upButton}
<span className="min-w-16 px-2 text-xs">
{isADiffInThisFile ?
`Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}`
: streamState === 'streaming' ?
'No changes yet'
: `No changes`
}
</span>
</div>
{/* Files */}
<div className={`${!isADiffZoneInAnyFile ? 'hidden' : ''} flex items-center ${leftRightDisabled ? 'opacity-50' : ''}`}>
{leftButton}
{/* <div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> */}
{rightButton}
{/* <div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> */}
<span className="min-w-16 px-2 text-xs">
{currFileIdx !== null ?
`File ${currFileIdx + 1} of ${sortedCommandBarURIs.length}`
: `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed`
}
</span>
</div>
</div>
</div>
return <div className={`flex flex-col items-center gap-y-2 mx-2 pointer-events-auto`}>
{showAcceptRejectAll && acceptRejectAllButtons}
{leftRightUpDownButtons}
</div>
}

View file

@ -0,0 +1,9 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { mountFnGenerator } from '../util/mountFnGenerator.js'
import { VoidCommandBarMain } from './VoidCommandBar.js'
export const mountVoidCommandBar = mountFnGenerator(VoidCommandBarMain)

View file

@ -21,7 +21,7 @@ const optionsEqual = (m1: ModelOption[], m2: ModelOption[]) => {
return true
}
const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], featureName: FeatureName }) => {
const ModelSelectBox = ({ options, featureName, className }: { options: ModelOption[], featureName: FeatureName, className: string }) => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
@ -40,7 +40,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat
getOptionDropdownName={(option) => option.selection.modelName}
getOptionDropdownDetail={(option) => option.selection.providerName}
getOptionsEqual={(a, b) => optionsEqual([a], [b])}
className='text-xs text-void-fg-3'
className={className}
matchInputWidth={false}
/>
}
@ -77,7 +77,7 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat
const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) => {
const MemoizedModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => {
const settingsState = useSettingsState()
const oldOptionsRef = useRef<ModelOption[]>([])
const [memoizedOptions, setMemoizedOptions] = useState(oldOptionsRef.current)
@ -86,7 +86,7 @@ const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) =>
useEffect(() => {
const oldOptions = oldOptionsRef.current
const newOptions = settingsState._modelOptions.filter((o) => filter(o.selection))
const newOptions = settingsState._modelOptions.filter((o) => filter(o.selection, { chatMode: settingsState.globalSettings.chatMode }))
if (!optionsEqual(oldOptions, newOptions)) {
setMemoizedOptions(newOptions)
@ -95,14 +95,14 @@ const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) =>
}, [settingsState._modelOptions, filter])
if (memoizedOptions.length === 0) { // Pretty sure this will never be reached unless filter is enabled
return <WarningBox text={emptyMessage || 'No models available'} />
return <WarningBox text={emptyMessage?.message || 'No models available'} />
}
return <ModelSelectBox featureName={featureName} options={memoizedOptions} />
return <ModelSelectBox featureName={featureName} options={memoizedOptions} className={className} />
}
export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) => {
export const ModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => {
const settingsState = useSettingsState()
const accessor = useAccessor()
@ -116,12 +116,12 @@ export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) =>
const isDisabled = isFeatureNameDisabled(featureName, settingsState)
if (isDisabled)
return <WarningBox onClick={openSettings} text={
emptyMessage ? emptyMessage :
emptyMessage && emptyMessage.priority === 'always' ? emptyMessage.message :
isDisabled === 'needToEnableModel' ? 'Enable a model'
: isDisabled === 'addModel' ? 'Add a model'
: (isDisabled === 'addProvider' || isDisabled === 'notFilledIn' || isDisabled === 'providerNotAutoDetected') ? 'Provider required'
: 'Provider required'
} />
return <MemoizedModelDropdown featureName={featureName} />
return <MemoizedModelDropdown featureName={featureName} className={className} />
}

View file

@ -18,13 +18,12 @@ import { ModelDropdown } from './ModelDropdown.js'
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'
import { WarningBox } from './WarningBox.js'
import { os } from '../../../../common/helpers/systemInfo.js'
import { IconX } from '../sidebar-tsx/SidebarChat.js'
const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => {
const ButtonLeftTextRightOption = ({ text, leftButton }: { text: string, leftButton?: React.ReactNode }) => {
return <div className='flex items-center text-void-fg-3 px-3 py-0.5 rounded-sm overflow-hidden gap-2 hover:bg-black/10 dark:hover:bg-gray-300/10'>
<button className='flex items-center' disabled={disabled} onClick={onClick}>
{icon}
</button>
{leftButton ? leftButton : null}
<span>
{text}
</span>
@ -57,22 +56,28 @@ const RefreshModelButton = ({ providerName }: { providerName: RefreshableProvide
const { state } = refreshModelState[providerName]
const { title: providerTitle } = displayInfoOfProviderName(providerName)
return <SubtleButton
onClick={() => {
refreshModelService.startRefreshingModels(providerName, { enableProviderOnSuccess: false, doNotFire: false })
metricsService.capture('Click', { providerName, action: 'Refresh Models' })
}}
text={justFinished === 'finished' ? `${providerTitle} Models are up-to-date!`
: justFinished === 'error' ? `${providerTitle} not found!`
: `Manually refresh ${providerTitle} models.`
}
icon={justFinished === 'finished' ? <Check className='stroke-green-500 size-3' />
: justFinished === 'error' ? <X className='stroke-red-500 size-3' />
: state === 'refreshing' ? <Loader2 className='size-3 animate-spin' />
: <RefreshCw className='size-3' />
return <ButtonLeftTextRightOption
leftButton={
<button
className='flex items-center'
disabled={state === 'refreshing' || justFinished !== null}
onClick={() => {
refreshModelService.startRefreshingModels(providerName, { enableProviderOnSuccess: false, doNotFire: false })
metricsService.capture('Click', { providerName, action: 'Refresh Models' })
}}
>
{justFinished === 'finished' ? <Check className='stroke-green-500 size-3' />
: justFinished === 'error' ? <X className='stroke-red-500 size-3' />
: state === 'refreshing' ? <Loader2 className='size-3 animate-spin' />
: <RefreshCw className='size-3' />}
</button>
}
disabled={state === 'refreshing' || justFinished !== null}
text={justFinished === 'finished' ? `${providerTitle} Models are up-to-date!`
: justFinished === 'error' ? `${providerTitle} not found!`
: `Manually refresh ${providerTitle} models.`}
/>
}
@ -93,7 +98,7 @@ const RefreshableModels = () => {
const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
const AddModelMenu = ({ onSubmit, onClose }: { onSubmit: () => void, onClose: () => void }) => {
const accessor = useAccessor()
const settingsStateService = accessor.get('IVoidSettingsService')
@ -116,8 +121,8 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
options={providerNames}
selectedOption={providerName}
onChangeOption={(pn) => setProviderName(pn)}
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : '(null)'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : '(null)'}
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionsEqual={(a, b) => a === b}
className={`max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root
py-[4px] px-[6px]
@ -141,8 +146,8 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
</div>
{/* button */}
<div className='max-w-40'>
<VoidButton onClick={() => {
<VoidButton
onClick={() => {
const modelName = modelNameRef.current?.value
if (providerName === null) {
@ -161,16 +166,16 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
settingsStateService.addModel(providerName, modelName)
onSubmit()
}}
>Add model</VoidButton>
</div>
>Add model</VoidButton>
{!errorString ? null : <div className='text-red-500 truncate whitespace-nowrap'>
{errorString}
</div>}
<button onClick={onClose} className='ml-auto'><X className='size-4' /></button>
</div>
{!errorString ? null : <div className='text-red-500 truncate whitespace-nowrap mt-1'>
{errorString}
</div>}
</>
}
@ -178,9 +183,9 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
const AddModelMenuFull = () => {
const [open, setOpen] = useState(false)
return <div className='hover:bg-black/10 dark:hover:bg-gray-300/10 py-1 my-4 pb-1 px-3 rounded-sm overflow-hidden '>
return <div className='hover:bg-black/10 dark:hover:bg-gray-300/10 py-1 my-4 px-3 rounded-sm overflow-hidden'>
{open ?
<AddModelMenu onSubmit={() => { setOpen(false) }} />
<AddModelMenu onSubmit={() => setOpen(false)} onClose={() => setOpen(false)} />
: <VoidButton onClick={() => setOpen(true)}>Add Model</VoidButton>
}
</div>
@ -354,7 +359,7 @@ export const VoidProviderSettings = ({ providerNames }: { providerNames: Provide
type TabName = 'models' | 'general'
export const AutoRefreshToggle = () => {
export const AutoDetectLocalModelsToggle = () => {
const settingName: GlobalSettingName = 'autoRefreshModels'
const accessor = useAccessor()
@ -366,19 +371,17 @@ export const AutoRefreshToggle = () => {
// right now this is just `enabled_autoRefreshModels`
const enabled = voidSettingsState.globalSettings[settingName]
return <div className='flex items-center px-3 gap-x-1.5'>
<VoidSwitch
return <ButtonLeftTextRightOption
leftButton={<VoidSwitch
size='xxs'
value={enabled}
onChange={(newVal) => {
voidSettingsService.setGlobalSetting(settingName, newVal)
metricsService.capture('Click', { action: 'Autorefresh Toggle', settingName, enabled: newVal })
}} />
<span className='text-void-fg-3'>
{`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`}
</span>
</div>
}}
/>}
text={`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`}
/>
}
@ -398,6 +401,30 @@ export const AIInstructionsBox = () => {
/>
}
const FastApplyMethodDropdown = () => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const options = useMemo(() => [true, false], [])
const onChangeOption = useCallback((newVal: boolean) => {
voidSettingsService.setGlobalSetting('enableFastApply', newVal)
}, [voidSettingsService])
return <VoidCustomDropdownBox
className='text-xs text-void-fg-3 bg-void-bg-1 border border-void-border-1 rounded p-0.5 px-1'
options={options}
selectedOption={voidSettingsService.state.globalSettings.enableFastApply}
onChangeOption={onChangeOption}
getOptionDisplayName={(val) => val ? 'Fast Apply' : 'Slow Apply'}
getOptionDropdownName={(val) => val ? 'Fast Apply' : 'Slow Apply'}
getOptionDropdownDetail={(val) => val ? 'Output Search/Replace blocks' : 'Rewrite whole files'}
getOptionsEqual={(a, b) => a === b}
/>
}
export const FeaturesTab = () => {
const voidSettingsState = useSettingsState()
const accessor = useAccessor()
@ -407,11 +434,10 @@ export const FeaturesTab = () => {
return <>
<h2 className={`text-3xl mb-2`}>Models</h2>
<ErrorBoundary>
<AutoRefreshToggle />
<RefreshableModels />
<div className='py-2' />
<ModelDump />
<AddModelMenuFull />
<AutoDetectLocalModelsToggle />
<RefreshableModels />
</ErrorBoundary>
@ -444,31 +470,94 @@ export const FeaturesTab = () => {
<h2 className={`text-3xl mt-12`}>Feature Options</h2>
<ErrorBoundary>
<div className='flex gap-x-4 items-start justify-around mt-4 mb-16'>
{/* L1 */}
<div className='flex items-start justify-around mt-4 my-4 gap-x-8'>
{/* FIM */}
<div className='w-full'>
<h4 className={`text-base`}>{displayInfoOfFeatureName('Autocomplete')}</h4>
<div className='text-sm italic text-void-fg-3 my-1'>Experimental. Only works with models that support FIM.</div>
<div className='flex items-center gap-x-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.enableAutocomplete}
onChange={(newVal) => voidSettingsService.setGlobalSetting('enableAutocomplete', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.enableAutocomplete ? 'Enabled' : 'Disabled'}</span>
<div className='text-sm italic text-void-fg-3 mt-1 mb-4'>Experimental. Only works with models that support FIM.</div>
<div className='my-2'>
{/* Enable Switch */}
<div className='flex items-center gap-x-2 my-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.enableAutocomplete}
onChange={(newVal) => voidSettingsService.setGlobalSetting('enableAutocomplete', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.enableAutocomplete ? 'Enabled' : 'Disabled'}</span>
</div>
{/* Model Dropdown */}
<div className={`my-2 ${!voidSettingsState.globalSettings.enableAutocomplete ? 'hidden' : ''}`}>
<ModelDropdown featureName={'Autocomplete'} className='text-xs text-void-fg-3 bg-void-bg-1 border border-void-border-1 rounded p-0.5 px-1' />
</div>
</div>
<div className={!voidSettingsState.globalSettings.enableAutocomplete ? 'hidden' : ''}>
<ModelDropdown featureName={'Autocomplete'} />
</div>
{/* Apply */}
<div className='w-full'>
<h4 className={`text-base`}>{displayInfoOfFeatureName('Apply')}</h4>
<div className='text-sm italic text-void-fg-3 mt-1 mb-4'>Settings that control the behavior of the Apply button and the Edit tool.</div>
<div className='my-2'>
{/* Sync to Chat Switch */}
<div className='flex items-center gap-x-2 my-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.syncApplyToChat}
onChange={(newVal) => voidSettingsService.setGlobalSetting('syncApplyToChat', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.syncApplyToChat ? 'Same as Chat model' : 'Different model'}</span>
</div>
{/* Model Dropdown */}
<div className={`my-2 ${voidSettingsState.globalSettings.syncApplyToChat ? 'hidden' : ''}`}>
<ModelDropdown featureName={'Apply'} className='text-xs text-void-fg-3 bg-void-bg-1 border border-void-border-1 rounded p-0.5 px-1' />
</div>
</div>
<div className='my-2'>
{/* Fast Apply Method Dropdown */}
<div className='flex items-center gap-x-2 my-2'>
<FastApplyMethodDropdown />
</div>
</div>
</div>
</div>
{/* L2 */}
<div className='flex items-start justify-around my-4 gap-x-8'>
{/* Tools Section */}
<div className='w-full'>
<h4 className={`text-base`}>Tools</h4>
<div className='text-sm italic text-void-fg-3 mt-1 mb-4'>{`Tools are functions that LLMs can call. Some tools require user approval.`}</div>
<div className='my-2'>
{/* Auto Accept Switch */}
<div className='flex items-center gap-x-2 my-2'>
<VoidSwitch
size='xs'
value={voidSettingsState.globalSettings.autoApprove}
onChange={(newVal) => voidSettingsService.setGlobalSetting('autoApprove', newVal)}
/>
<span className='text-void-fg-3 text-xs pointer-events-none'>{voidSettingsState.globalSettings.autoApprove ? 'Auto-approve' : 'Auto-approve'}</span>
</div>
</div>
</div>
<div className='w-full'>
<h4 className={`text-base`}>{displayInfoOfFeatureName('Apply')}</h4>
<div className='text-sm italic text-void-fg-3 my-1'>We recommend using Claude 3.7 or GPT 4o.</div>
<ModelDropdown featureName={'Apply'} />
</div>
</div>
<div className='py-8' />
</ErrorBoundary>
</>

View file

@ -9,7 +9,7 @@ module.exports = {
content: ['./src2/**/*.{jsx,tsx}'], // uses these files to decide how to transform the css file
theme: {
extend: {
typography: {
typography: theme => ({
DEFAULT: {
css: {
'--tw-prose-body': 'var(--void-fg-1)',
@ -30,8 +30,7 @@ module.exports = {
'--tw-prose-td-borders': 'var(--void-border-4)',
},
},
},
}),
fontSize: {
xs: '10px',
sm: '11px',

View file

@ -7,6 +7,7 @@ import { defineConfig } from 'tsup'
export default defineConfig({
entry: [
'./src2/void-command-bar-tsx/index.tsx',
'./src2/sidebar-tsx/index.tsx',
'./src2/void-settings-tsx/index.tsx',
'./src2/quick-edit-tsx/index.tsx',

View file

@ -7,8 +7,6 @@ import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// import { ILLMMessageService } from '../common/llmMessageService.js';
// import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js';
@ -17,7 +15,7 @@ export interface ISearchReplaceService {
}
export const ISearchReplaceService = createDecorator<ISearchReplaceService>('SearchReplaceCacheService');
class SearchReplaceService extends Disposable implements ISearchReplaceService {
export class SearchReplaceService extends Disposable implements ISearchReplaceService {
_serviceBrand: undefined;
private readonly _onDidChangeState = new Emitter<void>();

View file

@ -15,19 +15,15 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { VOID_VIEW_CONTAINER_ID, VOID_VIEW_ID } from './sidebarPane.js';
import { VOID_VIEW_ID } from './sidebarPane.js';
import { IMetricsService } from '../common/metricsService.js';
import { ISidebarStateService } from './sidebarStateService.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { VOID_TOGGLE_SETTINGS_ACTION_ID } from './voidSettingsPane.js';
import { VOID_CTRL_L_ACTION_ID } from './actionIDs.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { localize2 } from '../../../../nls.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
import { IVoidUriStateService } from './voidUriStateService.js';
import { StagingSelectionItem } from '../common/chatThreadServiceTypes.js';
import { IChatThreadService } from './chatThreadService.js';
@ -123,21 +119,23 @@ registerAction2(class extends Action2 {
const selection: StagingSelectionItem = !selectionRange || !selectionStr || (selectionRange.startLineNumber > selectionRange.endLineNumber) ? {
type: 'File',
fileURI: model.uri,
language: model.getLanguageId(),
selectionStr: null,
range: null,
state: { isOpened: false, }
state: { isOpened: false, wasAddedAsCurrentFile: false }
} : {
type: 'Selection',
fileURI: model.uri,
language: model.getLanguageId(),
selectionStr: selectionStr,
range: selectionRange,
state: { isOpened: true, }
state: { isOpened: true, wasAddedAsCurrentFile: false }
}
// update the staging selections
const chatThreadService = accessor.get(IChatThreadService)
const focusedMessageIdx = chatThreadService.getFocusedMessageIdx()
const focusedMessageIdx = chatThreadService.getCurrentFocusedMessageIdx()
// set the selections to the proper value
let selections: StagingSelectionItem[] = []
@ -284,43 +282,3 @@ export class TabSwitchListener extends Disposable {
this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) }))
}
}
class TabSwitchContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.void.tabswitch'
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IViewsService private readonly viewsService: IViewsService,
@IVoidUriStateService private readonly uriStateService: IVoidUriStateService,
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
// @ICommandService private readonly commandService: ICommandService,
) {
super()
// sidebarIsVisible state
let sidebarIsVisible = this.viewsService.isViewContainerVisible(VOID_VIEW_CONTAINER_ID)
this._register(this.viewsService.onDidChangeViewVisibility(e => {
sidebarIsVisible = e.visible
}))
const onSwitchTab = () => { // update state
if (sidebarIsVisible) {
const currentUri = this.codeEditorService.getActiveCodeEditor()?.getModel()?.uri
if (!currentUri) return;
this.uriStateService.setState({ currentUri })
// this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID)
}
}
// when sidebar becomes visible, add current file
this._register(this.viewsService.onDidChangeViewVisibility(e => { sidebarIsVisible = e.visible }))
// run on current tab if it exists, and listen for tab switches and visibility changes
onSwitchTab()
this._register(this.viewsService.onDidChangeViewVisibility(() => { onSwitchTab() }))
this._register(this.instantiationService.createInstance(TabSwitchListener, () => { onSwitchTab() }))
}
}
registerWorkbenchContribution2(TabSwitchContribution.ID, TabSwitchContribution, WorkbenchPhase.BlockRestore);

View file

@ -38,7 +38,7 @@ import { mountSidebar } from './react/out/sidebar-tsx/index.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Orientation } from '../../../../base/browser/ui/sash/sash.js';
// import { IDisposable } from '../../../../base/common/lifecycle.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import { toDisposable } from '../../../../base/common/lifecycle.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
@ -80,8 +80,8 @@ class SidebarViewPane extends ViewPane {
// gets set immediately
this.instantiationService.invokeFunction(accessor => {
// mount react
const disposables: IDisposable[] | undefined = mountSidebar(parent, accessor);
disposables?.forEach(d => this._register(d))
const disposeFn: (() => void) | undefined = mountSidebar(parent, accessor)?.dispose;
this._register(toDisposable(() => disposeFn?.()))
});
}

View file

@ -3,23 +3,39 @@
* 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 { removeAnsiEscapeCodes } from '../../../../base/common/strings.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 { TerminalResolveReason } 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 }>;
listTerminalIds(): string[];
runCommand(command: string, proposedTerminalId: string, waitForCompletion: boolean): Promise<{ terminalId: string, didCreateTerminal: boolean, result: string, resolveReason: TerminalResolveReason }>;
openTerminal(terminalId: string): Promise<void>
terminalExists(terminalId: string): boolean
}
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})`
@ -43,13 +59,28 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ
) {
super();
// initialize any terminals that are already open
// 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) })
)
}
@ -77,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]
if (!terminal) return // should never happen
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
}
runCommand: ITerminalToolService['runCommand'] = async (command, proposedTerminalId, waitForCompletion) => {
@ -95,37 +157,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.`);
// focus the terminal about to run
this.terminalService.setActiveInstance(terminal)
await this.terminalService.focusActiveInstance()
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: TerminalResolveReason | undefined = undefined
// stream
const disposables: IDisposable[] = []
let data = ''
const d1 = terminal.onData(newData => { data += newData })
const waitUntilDone = new Promise<void>((res, rej) => {
const d2 = terminal.onData(async newData => {
if (resolveReason) return
// terminal.onExit(() => {
// console.log('TERMINALEXIT')
// })
result += newData
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 }
// 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
resolveReason = { type: 'toofull' }
res()
return
}
// onDone
const isDone = isCommandComplete(result)
if (isDone) {
resolveReason = { type: 'done', exitCode: isDone.exitCode }
res()
return
}
})
}
disposables.push(d2)
})
console.log('didnot wait', data)
d1.dispose()
return { terminalId, didCreateTerminal, contents: 'Could not await data...' }
// 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()
return
}, (waitForCompletion ? TERMINAL_TIMEOUT_TIME : TERMINAL_BG_WAIT_TIME) * 1000)
})
await Promise.any([
waitUntilDone,
waitUntilTimeout,
])
disposables.forEach(d => d.dispose())
if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.')
result = removeAnsiEscapeCodes(result)
.split('\n').slice(1, -1) // remove first and last line (first = command, last = andrewpareles/void %)
.join('\n')
return { terminalId, didCreateTerminal, result, resolveReason }
}

View file

@ -7,9 +7,12 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/
import { QueryBuilder } from '../../../services/search/common/queryBuilder.js'
import { ISearchService } from '../../../services/search/common/search.js'
import { IEditCodeService } from './editCodeServiceInterface.js'
import { IVoidFileService } from '../common/voidFileService.js'
import { ITerminalToolService } from './terminalToolService.js'
import { ToolCallParams, ToolDirectoryItem, ToolName, ToolResultType } from '../common/toolsServiceTypes.js'
import { IVoidModelService } from '../common/voidModelService.js'
import { EndOfLinePreference } from '../../../../editor/common/model.js'
import { basename } from '../../../../base/common/path.js'
import { IVoidCommandBarService } from './voidCommandBarService.js'
// tool use for AI
@ -18,7 +21,7 @@ import { ToolCallParams, ToolDirectoryItem, ToolName, ToolResultType } from '../
type ValidateParams = { [T in ToolName]: (p: string) => Promise<ToolCallParams[T]> }
type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<ToolResultType[T]> }
type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T], interruptTool?: () => void }> }
type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: ToolResultType[T]) => string }
@ -27,6 +30,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 = 20_000
export const TERMINAL_TIMEOUT_TIME = 15
export const TERMINAL_BG_WAIT_TIME = 1
@ -72,8 +78,8 @@ const directoryResultToString = (params: ToolCallParams['list_dir'], result: Too
let output = '';
const entries = result.children;
if (!result.hasPrevPage) {
output += `${params.rootURI}\n`;
if (!result.hasPrevPage) { // is first page
output += `${params.rootURI.fsPath}\n`;
}
for (let i = 0; i < entries.length; i++) {
@ -98,24 +104,30 @@ const directoryResultToString = (params: ToolCallParams['list_dir'], result: Too
const validateJSON = (s: string): { [s: string]: unknown } => {
try {
const o = JSON.parse(s)
if (typeof o !== 'object') throw new Error()
if ('result' in o) { // openrouter sometimes wraps the result with { 'result': ... }
return o.result
}
return o
}
catch (e) {
throw new Error(`Tool parameter was not a string of a valid JSON: "${s}".`)
throw new Error(`Invalid LLM output format: Tool parameter was not a string of a valid JSON: "${s}".`)
}
}
const validateStr = (argName: string, value: unknown) => {
if (typeof value !== 'string') throw new Error(`Error: ${argName} must be a string.`)
if (typeof value !== 'string') throw new Error(`Invalid LLM output format: ${argName} must be a string.`)
return value
}
// 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.')
if (typeof uriStr !== 'string') throw new Error('Invalid LLM output format: Provided uri must be a string.')
const uri = URI.file(uriStr)
return uri
@ -125,12 +137,12 @@ const validatePageNum = (pageNumberUnknown: unknown) => {
if (!pageNumberUnknown) return 1
const parsedInt = Number.parseInt(pageNumberUnknown + '')
if (!Number.isInteger(parsedInt)) throw new Error(`Page number was not an integer: "${pageNumberUnknown}".`)
if (parsedInt < 1) throw new Error(`Specified page number must be 1 or greater: "${pageNumberUnknown}".`)
if (parsedInt < 1) throw new Error(`Invalid LLM output format: Specified page number must be 1 or greater: "${pageNumberUnknown}".`)
return parsedInt
}
const validateRecursiveParamStr = (paramsUnknown: unknown) => {
if (typeof paramsUnknown !== 'string') throw new Error('Error calling tool: provided params must be a string.')
if (typeof paramsUnknown !== 'string') throw new Error('Invalid LLM output format: Error calling tool: provided params must be a string.')
const params = paramsUnknown
const isRecursive = params.includes('r')
return isRecursive
@ -149,6 +161,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;
@ -166,15 +186,15 @@ export class ToolsService implements IToolsService {
public callTool: CallTool;
public stringOfResult: ToolResultToString;
constructor(
@IFileService fileService: IFileService,
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
@ISearchService searchService: ISearchService,
@IInstantiationService instantiationService: IInstantiationService,
@IVoidFileService voidFileService: IVoidFileService,
@IVoidModelService voidModelService: IVoidModelService,
@IEditCodeService editCodeService: IEditCodeService,
@ITerminalToolService private readonly terminalToolService: ITerminalToolService,
@IVoidCommandBarService private readonly commandBarService: IVoidCommandBarService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
@ -207,7 +227,7 @@ export class ToolsService implements IToolsService {
return { queryStr, pageNumber }
},
search: async (params: string) => {
text_search: async (params: string) => {
const o = validateJSON(params)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
@ -221,17 +241,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) => {
@ -239,7 +263,6 @@ export class ToolsService implements IToolsService {
const { uri: uriStr, changeDescription: changeDescriptionUnknown } = o
const uri = validateURI(uriStr)
const changeDescription = validateStr('changeDescription', changeDescriptionUnknown)
return { uri, changeDescription }
},
@ -257,22 +280,28 @@ export class ToolsService implements IToolsService {
this.callTool = {
read_file: async ({ uri, pageNumber }) => {
const readFileContents = await voidFileService.readFile(uri)
await voidModelService.initializeModel(uri)
const { model } = await voidModelService.getModelSafe(uri)
if (model === null) { throw new Error(`Contents were empty. There may have been an error, or the file may not exist.`) }
const readFileContents = model.getValue(EndOfLinePreference.LF)
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
return { fileContents, hasNextPage }
return { result: { fileContents, hasNextPage } }
},
list_dir: async ({ rootURI, pageNumber }) => {
const dirResult = await computeDirectoryResult(fileService, rootURI, pageNumber)
return dirResult
return { result: dirResult }
},
pathname_search: async ({ queryStr, pageNumber }) => {
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, })
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), {
filePattern: queryStr,
})
const data = await searchService.fileSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
@ -282,11 +311,15 @@ export class ToolsService implements IToolsService {
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { uris, hasNextPage }
return { result: { uris, hasNextPage } }
},
search: async ({ queryStr, pageNumber }) => {
const query = queryBuilder.text({ pattern: queryStr, }, workspaceContextService.getWorkspace().folders.map(f => f.uri))
text_search: async ({ queryStr, pageNumber }) => {
const query = queryBuilder.text({
pattern: queryStr,
isRegExp: true,
}, workspaceContextService.getWorkspace().folders.map(f => f.uri))
const data = await searchService.textSearch(query, CancellationToken.None)
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
@ -296,34 +329,47 @@ export class ToolsService implements IToolsService {
.map(({ resource, results }) => resource)
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { queryStr, uris, hasNextPage }
return { result: { queryStr, uris, hasNextPage } }
},
// ---
create_uri: async ({ uri }) => {
await fileService.createFile(uri)
return {}
create_uri: async ({ uri, isFolder }) => {
if (isFolder)
await fileService.createFolder(uri)
else {
await fileService.createFile(uri)
}
return { result: {} }
},
delete_uri: async ({ uri, isRecursive }) => {
await fileService.del(uri, { recursive: isRecursive })
return {}
return { result: {} }
},
edit: async ({ uri, changeDescription }) => {
const [_, applyDonePromise] = editCodeService.startApplying({
await voidModelService.initializeModel(uri)
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`The Apply model was already running. This can happen if two agents try editing the same file at the same time. Please try again in a moment.`)
}
const res = await editCodeService.startApplying({
uri,
applyStr: changeDescription,
from: 'ClickApply',
type: 'searchReplace',
}) ?? []
await applyDonePromise
return {}
startBehavior: 'keep-conflicts',
})
if (!res) throw new Error(`The Apply model did not start running on ${basename(uri.fsPath)}. Please try again.`)
const [diffZoneURI, applyDonePromise] = res
const interruptTool = () => { // must reject the applyPromiseDone promise
editCodeService.interruptURIStreaming({ uri: diffZoneURI })
}
return { result: applyDonePromise, interruptTool }
},
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 { result: { terminalId, didCreateTerminal, result, resolveReason } }
},
}
@ -337,12 +383,12 @@ export class ToolsService implements IToolsService {
},
list_dir: (params, result) => {
const dirTreeStr = directoryResultToString(params, result)
return dirTreeStr + nextPageStr(result.hasNextPage)
return dirTreeStr // + nextPageStr(result.hasNextPage) // already handles num results remaining
},
pathname_search: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search: (params, result) => {
text_search: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
// ---
@ -353,10 +399,33 @@ export class ToolsService implements IToolsService {
return `URI ${params.uri.fsPath} successfully deleted.`
},
edit: (params, result) => {
return `Change successfully made ${params.uri.fsPath} successfully deleted.`
console.log('STR OF RESULT', params)
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

@ -40,7 +40,11 @@ import './terminalToolService.js'
// register Thread History
import './chatThreadService.js'
// ping
import './metricsPollService.js'
// helper services
import './helperServices/consistentItemService.js'
// ---------- common (unclear if these actually need to be imported, because they're already imported wherever they're used) ----------
@ -59,3 +63,5 @@ import '../common/metricsService.js'
// updates
import '../common/voidUpdateService.js'
// model service
import '../common/voidModelService.js'

View file

@ -0,0 +1,443 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { URI } from '../../../../base/common/uri.js';
import * as dom from '../../../../base/browser/dom.js';
import { Widget } from '../../../../base/browser/ui/widget.js';
import { IOverlayWidget, ICodeEditor, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { mountVoidCommandBar } from './react/out/void-command-bar-tsx/index.js'
import { deepClone } from '../../../../base/common/objects.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { IEditCodeService } from './editCodeServiceInterface.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { generateUuid } from '../../../../base/common/uuid.js';
export interface IVoidCommandBarService {
readonly _serviceBrand: undefined;
stateOfURI: { [uri: string]: CommandBarStateType };
sortedURIs: URI[];
activeURI: URI | null;
onDidChangeState: Event<{ uri: URI }>;
onDidChangeActiveURI: Event<{ uri: URI | null }>;
getStreamState: (uri: URI) => 'streaming' | 'idle-has-changes' | 'idle-no-changes';
setDiffIdx(uri: URI, newIdx: number | null): void;
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }): void;
anyFileIsStreaming(): boolean;
}
export const IVoidCommandBarService = createDecorator<IVoidCommandBarService>('VoidCommandBarService');
export type CommandBarStateType = undefined | {
sortedDiffZoneIds: string[]; // sorted by line number
sortedDiffIds: string[]; // sorted by line number (computed)
isStreaming: boolean; // is any diffZone streaming in this URI
diffIdx: number | null; // must refresh whenever sortedDiffIds does so it's valid
}
const defaultState: NonNullable<CommandBarStateType> = {
sortedDiffZoneIds: [],
sortedDiffIds: [],
isStreaming: false,
diffIdx: null,
}
export class VoidCommandBarService extends Disposable implements IVoidCommandBarService {
_serviceBrand: undefined;
static readonly ID: 'void.VoidCommandBarService'
// depends on uri -> diffZone -> {streaming, diffs}
public stateOfURI: { [uri: string]: CommandBarStateType } = {}
public sortedURIs: URI[] = [] // keys of state (depends on diffZones in the uri)
private readonly _listenToTheseURIs = new Set<URI>() // uriFsPaths
// Emits when a URI's stream state changes between idle, streaming, and acceptRejectAll
private readonly _onDidChangeState = new Emitter<{ uri: URI }>();
readonly onDidChangeState = this._onDidChangeState.event;
// active URI
activeURI: URI | null = null;
private readonly _onDidChangeActiveURI = new Emitter<{ uri: URI | null }>();
readonly onDidChangeActiveURI = this._onDidChangeActiveURI.event;
constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
@IModelService private readonly _modelService: IModelService,
@IEditCodeService private readonly _editCodeService: IEditCodeService,
) {
super();
const registeredModelURIs = new Set<string>()
const initializeModel = async (model: ITextModel) => {
// do not add listeners to the same model twice - important, or will see duplicates
if (registeredModelURIs.has(model.uri.fsPath)) return
registeredModelURIs.add(model.uri.fsPath)
this._listenToTheseURIs.add(model.uri)
}
// initialize all existing models + initialize when a new model mounts
this._modelService.getModels().forEach(model => { initializeModel(model) })
this._register(this._modelService.onModelAdded(model => { initializeModel(model) }));
// for every new editor, add the floating widget and update active URI
const disposablesOfEditorId: { [editorId: string]: IDisposable[] } = {};
const onCodeEditorAdd = (editor: ICodeEditor) => {
const id = editor.getId();
disposablesOfEditorId[id] = [];
// mount the command bar
const d1 = this._instantiationService.createInstance(AcceptRejectAllFloatingWidget, { editor });
disposablesOfEditorId[id].push(d1);
const d2 = editor.onDidChangeModel((e) => {
if (e.newModelUrl?.scheme !== 'file') return
this.activeURI = e.newModelUrl;
this._onDidChangeActiveURI.fire({ uri: e.newModelUrl })
})
disposablesOfEditorId[id].push(d2);
}
const onCodeEditorRemove = (editor: ICodeEditor) => {
const id = editor.getId();
if (disposablesOfEditorId[id]) {
disposablesOfEditorId[id].forEach(d => d.dispose());
delete disposablesOfEditorId[id];
}
}
this._register(this._codeEditorService.onCodeEditorAdd((editor) => { onCodeEditorAdd(editor) }))
this._register(this._codeEditorService.onCodeEditorRemove((editor) => { onCodeEditorRemove(editor) }))
this._codeEditorService.listCodeEditors().forEach(editor => { onCodeEditorAdd(editor) })
// state updaters
this._register(this._editCodeService.onDidAddOrDeleteDiffZones(e => {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: delete if empty, add if not in state yet
const diffZones = this._getDiffZonesOnURI(uri)
if (diffZones.length === 0) {
this._deleteURIEntryFromState(uri)
this._onDidChangeState.fire({ uri })
continue // deleted, so done
}
if (!this.sortedURIs.find(uri2 => uri2.fsPath === uri.fsPath)) {
this._addURIEntryToState(uri)
}
const currState = this.stateOfURI[uri.fsPath]
if (!currState) continue // should never happen
// update state of the diffZones on this URI
const oldDiffZones = currState.sortedDiffZoneIds
const currentDiffZones = this._editCodeService.diffAreasOfURI[uri.fsPath] || [] // a Set
const { addedDiffZones, deletedDiffZones } = this._getDiffZoneChanges(oldDiffZones, currentDiffZones || [])
const diffZonesWithoutDeleted = oldDiffZones.filter(olddiffareaid => !deletedDiffZones.has(olddiffareaid))
// --- new state:
const newSortedDiffZoneIds = [
...diffZonesWithoutDeleted,
...addedDiffZones,
]
const newSortedDiffIds = this._computeSortedDiffs(newSortedDiffZoneIds)
const isStreaming = this._isAnyDiffZoneStreaming(currentDiffZones)
this._setState(uri, {
sortedDiffZoneIds: newSortedDiffZoneIds,
sortedDiffIds: newSortedDiffIds,
isStreaming: isStreaming
})
this._onDidChangeState.fire({ uri })
}
}))
this._register(this._editCodeService.onDidChangeDiffsInDiffZone(e => {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: no change
// --- state:
// sortedDiffIds gets a change to it, so gets recomputed
const currState = this.stateOfURI[uri.fsPath]
if (!currState) continue // should never happen
const { sortedDiffZoneIds } = currState
const newSortedDiffIds = this._computeSortedDiffs(sortedDiffZoneIds)
this._setState(uri, {
sortedDiffIds: newSortedDiffIds,
// sortedDiffZoneIds, // no change
// isStreaming, // no change
})
this._onDidChangeState.fire({ uri })
}
}))
this._register(this._editCodeService.onDidChangeStreamingInDiffZone(e => {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: no change
// --- state:
const currState = this.stateOfURI[uri.fsPath]
if (!currState) continue // should never happen
const { sortedDiffZoneIds } = currState
this._setState(uri, {
isStreaming: this._isAnyDiffZoneStreaming(sortedDiffZoneIds),
// sortedDiffIds, // no change
// sortedDiffZoneIds, // no change
})
this._onDidChangeState.fire({ uri })
}
}))
}
setDiffIdx(uri: URI, newIdx: number | null): void {
this._setState(uri, { diffIdx: newIdx });
this._onDidChangeState.fire({ uri });
}
getStreamState(uri: URI) {
const { isStreaming, sortedDiffZoneIds } = this.stateOfURI[uri.fsPath] ?? {}
if (isStreaming) {
return 'streaming'
}
if ((sortedDiffZoneIds?.length ?? 0) > 0) {
return 'idle-has-changes'
}
return 'idle-no-changes'
}
_computeSortedDiffs(diffareaids: string[]) {
const sortedDiffIds = [];
for (const diffareaid of diffareaids) {
const diffZone = this._editCodeService.diffAreaOfId[diffareaid];
if (!diffZone || diffZone.type !== 'DiffZone') {
continue;
}
// Add all diff ids from this diffzone
const diffIds = Object.keys(diffZone._diffOfId);
sortedDiffIds.push(...diffIds);
}
return sortedDiffIds;
}
_getDiffZoneChanges(oldDiffZones: Iterable<string>, currentDiffZones: Iterable<string>) {
// Find the added or deleted diffZones by comparing diffareaids
const addedDiffZoneIds = new Set<string>();
const deletedDiffZoneIds = new Set<string>();
// Convert the current diffZones to a set of ids for easy lookup
const currentDiffZoneIdSet = new Set(currentDiffZones);
// Find deleted diffZones (in old but not in current)
for (const oldDiffZoneId of oldDiffZones) {
if (!currentDiffZoneIdSet.has(oldDiffZoneId)) {
const diffZone = this._editCodeService.diffAreaOfId[oldDiffZoneId];
if (diffZone && diffZone.type === 'DiffZone') {
deletedDiffZoneIds.add(oldDiffZoneId);
}
}
}
// Find added diffZones (in current but not in old)
const oldDiffZoneIdSet = new Set(oldDiffZones);
for (const currentDiffZoneId of currentDiffZones) {
if (!oldDiffZoneIdSet.has(currentDiffZoneId)) {
const diffZone = this._editCodeService.diffAreaOfId[currentDiffZoneId];
if (diffZone && diffZone.type === 'DiffZone') {
addedDiffZoneIds.add(currentDiffZoneId);
}
}
}
return { addedDiffZones: addedDiffZoneIds, deletedDiffZones: deletedDiffZoneIds }
}
_isAnyDiffZoneStreaming(diffareaids: Iterable<string>) {
for (const diffareaid of diffareaids) {
const diffZone = this._editCodeService.diffAreaOfId[diffareaid];
if (!diffZone || diffZone.type !== 'DiffZone') {
continue;
}
if (diffZone._streamState.isStreaming) {
return true;
}
}
return false
}
_setState(uri: URI, opts: Partial<CommandBarStateType>) {
const newState = {
...this.stateOfURI[uri.fsPath] ?? deepClone(defaultState),
...opts
}
// make sure diffIdx is always correct
if (newState.diffIdx && newState.diffIdx > newState.sortedDiffIds.length) {
newState.diffIdx = newState.sortedDiffIds.length
if (newState.diffIdx < 0) newState.diffIdx = null
}
this.stateOfURI = {
...this.stateOfURI,
[uri.fsPath]: newState
}
}
_addURIEntryToState(uri: URI) {
// add to sortedURIs
this.sortedURIs = [
...this.sortedURIs,
uri
]
// add to state
this.stateOfURI[uri.fsPath] = deepClone(defaultState)
}
_deleteURIEntryFromState(uri: URI) {
// delete this from sortedURIs
const i = this.sortedURIs.findIndex(uri2 => uri2.fsPath === uri.fsPath)
if (i === -1) return
this.sortedURIs = [
...this.sortedURIs.slice(0, i),
...this.sortedURIs.slice(i + 1, Infinity),
]
// delete from state
delete this.stateOfURI[uri.fsPath]
}
private _getDiffZonesOnURI(uri: URI) {
const diffZones = [...this._editCodeService.diffAreasOfURI[uri.fsPath]?.values() ?? []]
.map(diffareaid => this._editCodeService.diffAreaOfId[diffareaid])
.filter(diffArea => !!diffArea && diffArea.type === 'DiffZone')
return diffZones
}
anyFileIsStreaming() {
return this.sortedURIs.some(uri => this.getStreamState(uri) === 'streaming')
}
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }) {
const { behavior } = opts
// if anything is streaming, do nothing
const anyIsStreaming = this.anyFileIsStreaming()
if (anyIsStreaming) return
for (const uri of this.sortedURIs) {
this._editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior, removeCtrlKs: false })
}
}
}
registerSingleton(IVoidCommandBarService, VoidCommandBarService, InstantiationType.Delayed); // delayed is needed here :(
// registerWorkbenchContribution2(VoidCommandBarService.ID, VoidCommandBarService, WorkbenchPhase.BlockRestore);
export type VoidCommandBarProps = {
uri: URI | null;
editor: ICodeEditor;
}
class AcceptRejectAllFloatingWidget extends Widget implements IOverlayWidget {
private readonly _domNode: HTMLElement;
private readonly editor: ICodeEditor;
private readonly ID: string;
_height = 0
constructor({ editor }: { editor: ICodeEditor, },
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this.ID = generateUuid();
this.editor = editor;
// Create container div
const { root } = dom.h('div@root');
// Style the container
// root.style.backgroundColor = 'rgb(248 113 113)';
root.style.height = '16rem'; // make a fixed size, and all contents go on the bottom right. this fixes annoying VS Code mounting issues
root.style.width = '16rem';
root.style.flexDirection = 'column';
root.style.justifyContent = 'flex-end';
root.style.alignItems = 'flex-end';
root.style.zIndex = '2';
root.style.padding = '4px';
root.style.pointerEvents = 'none';
root.style.display = 'flex';
root.style.overflow = 'hidden';
this._domNode = root;
editor.addOverlayWidget(this);
this.instantiationService.invokeFunction(accessor => {
const uri = editor.getModel()?.uri || null
const res = mountVoidCommandBar(root, accessor, { uri, editor } satisfies VoidCommandBarProps)
if (!res) return
this._register(toDisposable(() => res.dispose?.()))
this._register(editor.onWillChangeModel((model) => {
const uri = model.newModelUrl
res.rerender({ uri, editor } satisfies VoidCommandBarProps)
}))
})
}
public getId(): string {
return this.ID;
}
public getDomNode(): HTMLElement {
return this._domNode;
}
public getPosition() {
return {
preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER
}
}
public override dispose(): void {
this.editor.removeOverlayWidget(this);
super.dispose();
}
}

View file

@ -25,7 +25,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
import { mountVoidSettings } from './react/out/void-settings-tsx/index.js'
import { Codicon } from '../../../../base/common/codicons.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import { toDisposable } from '../../../../base/common/lifecycle.js';
// refer to preferences.contribution.ts keybindings editor
@ -90,12 +90,12 @@ class VoidSettingsPane extends EditorPane {
// Mount React into the scrollable content
this.instantiationService.invokeFunction(accessor => {
const disposables: IDisposable[] | undefined = mountVoidSettings(settingsElt, accessor);
const disposeFn = mountVoidSettings(settingsElt, accessor)?.dispose;
this._register(toDisposable(() => disposeFn?.()))
// setTimeout(() => { // this is a complete hack and I don't really understand how scrollbar works here
// this._scrollbar?.scanDomNode();
// }, 1000)
disposables?.forEach(d => this._register(d));
});
}

View file

@ -80,14 +80,14 @@ class VoidUpdateWorkbenchContribution extends Disposable implements IWorkbenchCo
}
// check once 5 seconds after mount
const initId = setTimeout(() => autoCheck(), 5 * 1000)
this._register({ dispose: () => clearTimeout(initId) })
// check every 3 hours
const { window } = dom.getActiveWindow()
const intervalId = window.setInterval(() => autoCheck(), 3 * 60 * 60 * 1000)
const initId = window.setTimeout(() => autoCheck(), 5 * 1000)
this._register({ dispose: () => window.clearTimeout(initId) })
const intervalId = window.setInterval(() => autoCheck(), 3 * 60 * 60 * 1000) // every 3 hrs
this._register({ dispose: () => window.clearInterval(intervalId) })
}

View file

@ -1,57 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// service that manages state
export type VoidUriState = {
currentUri?: URI
}
export interface IVoidUriStateService {
readonly _serviceBrand: undefined;
readonly state: VoidUriState; // readonly to the user
setState(newState: Partial<VoidUriState>): void;
onDidChangeState: Event<void>;
}
export const IVoidUriStateService = createDecorator<IVoidUriStateService>('voidUriStateService');
class VoidUriStateService extends Disposable implements IVoidUriStateService {
_serviceBrand: undefined;
static readonly ID = 'voidUriStateService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
// state
state: VoidUriState
constructor(
) {
super()
// initial state
this.state = { currentUri: undefined }
}
setState(newState: Partial<VoidUriState>) {
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
}
registerSingleton(IVoidUriStateService, VoidUriStateService, InstantiationType.Eager);

View file

@ -9,13 +9,19 @@ 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] }
}
export type ToolRequestApproval<T extends ToolName> = {
role: 'tool_request';
name: T; // internal use
params: ToolCallParams[T]; // internal use
voidToolId: string; // internal id Void uses
paramsStr: string; // internal use - this is what the LLM outputted, not necessarily JSON.stringify(params)
id: string; // proposed tool's id
}
// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors.
@ -23,7 +29,7 @@ export type ChatMessage =
| {
role: 'user';
content: string; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty)
displayContent: string | null; // content displayed to user - allowed to be '', will be ignored
displayContent: string; // content displayed to user - allowed to be '', will be ignored
selections: StagingSelectionItem[] | null; // the user's selection
state: {
stagingSelections: StagingSelectionItem[];
@ -44,20 +50,24 @@ export type ChatMessage =
export type CodeSelection = {
type: 'Selection';
fileURI: URI;
language: string;
selectionStr: string;
range: IRange;
state: {
isOpened: boolean;
wasAddedAsCurrentFile: boolean;
};
}
export type FileSelection = {
type: 'File';
fileURI: URI;
language: string;
selectionStr: null;
range: null;
state: {
isOpened: boolean;
wasAddedAsCurrentFile: boolean;
};
}
@ -67,6 +77,7 @@ export type StagingSelectionItem = CodeSelection | FileSelection
export type CodespanLocationLink = {
uri: URI, // we handle serialization for this
displayText: string,
selection?: { // store as JSON so dont have to worry about serialization
startLineNumber: number
startColumn: number,

View file

@ -0,0 +1,8 @@
export const acceptBg = '#1a7431'
export const acceptAllBg = '#1e8538'
export const acceptBorder = '1px solid #145626'
export const rejectBg = '#b42331'
export const rejectAllBg = '#cf2838'
export const rejectBorder = '1px solid #8e1c27'
export const buttonFontSize = '11px'
export const buttonTextColor = 'white'

View file

@ -1,174 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
// eg "bash" -> "shell"
export const nameToVscodeLanguage: { [key: string]: string } = {
// Web Technologies
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'scss',
'less': 'less',
'javascript': 'typescript',
'js': 'typescript', // use more general renderer
'jsx': 'typescript',
'typescript': 'typescript',
'ts': 'typescript',
'tsx': 'typescript',
'json': 'json',
'jsonc': 'json',
// Programming Languages
'python': 'python',
'py': 'python',
'java': 'java',
'cpp': 'cpp',
'c++': 'cpp',
'c': 'c',
'csharp': 'csharp',
'cs': 'csharp',
'c#': 'csharp',
'go': 'go',
'golang': 'go',
'rust': 'rust',
'rs': 'rust',
'ruby': 'ruby',
'rb': 'ruby',
'php': 'php',
'shell': 'shell',
'bash': 'shell',
'sh': 'shell',
'zsh': 'shell',
// Markup and Config
'markdown': 'markdown',
'md': 'markdown',
'xml': 'xml',
'svg': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'ini': 'ini',
'toml': 'ini',
// Database and Query Languages
'sql': 'sql',
'mysql': 'sql',
'postgresql': 'sql',
'graphql': 'graphql',
'gql': 'graphql',
// Others
'dockerfile': 'dockerfile',
'docker': 'dockerfile',
'makefile': 'makefile',
'plaintext': 'plaintext',
'text': 'plaintext'
};
// eg ".ts" -> "typescript"
const fileExtensionToVscodeLanguage: { [key: string]: string } = {
// Web
'html': 'html',
'htm': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'json': 'json',
'jsonc': 'json',
// Programming Languages
'py': 'python',
'java': 'java',
'cpp': 'cpp',
'cc': 'cpp',
'c': 'c',
'h': 'cpp',
'hpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'rs': 'rust',
'rb': 'ruby',
'php': 'php',
'sh': 'shell',
'bash': 'shell',
'zsh': 'shell',
// Markup/Config
'md': 'markdown',
'markdown': 'markdown',
'xml': 'xml',
'svg': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'ini': 'ini',
'toml': 'ini',
// Other
'sql': 'sql',
'graphql': 'graphql',
'gql': 'graphql',
'dockerfile': 'dockerfile',
'docker': 'dockerfile',
'mk': 'makefile',
// Config Files and Dot Files
'npmrc': 'ini',
'env': 'ini',
'gitignore': 'ignore',
'dockerignore': 'ignore',
'eslintrc': 'json',
'babelrc': 'json',
'prettierrc': 'json',
'stylelintrc': 'json',
'editorconfig': 'ini',
'htaccess': 'apacheconf',
'conf': 'ini',
'config': 'ini',
// Package Files
'package': 'json',
'package-lock': 'json',
'gemfile': 'ruby',
'podfile': 'ruby',
'rakefile': 'ruby',
// Build Systems
'cmake': 'cmake',
'makefile': 'makefile',
'gradle': 'groovy',
// Shell Scripts
'bashrc': 'shell',
'zshrc': 'shell',
'fish': 'shell',
// Version Control
'gitconfig': 'ini',
'hgrc': 'ini',
'svnconfig': 'ini',
// Web Server
'nginx': 'nginx',
// Misc Config
'properties': 'properties',
'cfg': 'ini',
'reg': 'ini'
};
export function filenameToVscodeLanguage(filename: string): string | undefined {
const ext = filename.toLowerCase().split('.').pop();
if (!ext) return undefined;
return fileExtensionToVscodeLanguage[ext];
}

View file

@ -264,7 +264,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
onText_(params)
}
const newOnText: OnText = ({ fullText: fullText_ }) => {
const newOnText: OnText = ({ fullText: fullText_, ...p }) => {
// until found the first think tag, keep adding to fullText
if (!foundTag1) {
const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0])
@ -282,7 +282,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
fullTextSoFar += fullText_.substring(0, tag1Index)
// Update latestAddIdx to after the first tag
latestAddIdx = tag1Index + thinkTags[0].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
@ -290,7 +290,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
// add the text to fullText
fullTextSoFar = fullText_
latestAddIdx = fullText_.length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
@ -314,7 +314,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index)
// Update latestAddIdx to after the second tag
latestAddIdx = tag2Index + thinkTags[1].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
@ -327,7 +327,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
@ -340,7 +340,7 @@ export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ ...p, fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
}
return newOnText

View file

@ -0,0 +1,197 @@
// /*--------------------------------------------------------------------------------------
// * Copyright 2025 Glass Devtools, Inc. All rights reserved.
// * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
// *--------------------------------------------------------------------------------------*/
import { URI } from '../../../../../base/common/uri.js';
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
import { separateOutFirstLine } from './util.js';
// this works better than model.getLanguageId()
export function detectLanguage(languageService: ILanguageService, opts: { uri: URI | null, fileContents: string | undefined }) {
const firstLine = opts.fileContents ? separateOutFirstLine(opts.fileContents)?.[0] : undefined
const fullLang = languageService.createByFilepathOrFirstLine(opts.uri, firstLine)
return fullLang.languageId || 'plaintext'
}
// --- conversions
export const convertToVscodeLang = (languageService: ILanguageService, markdownLang: string) => {
if (markdownLang in markdownLangToVscodeLang)
return markdownLangToVscodeLang[markdownLang]
const { languageId } = languageService.createById(markdownLang)
return languageId
}
// // eg "bash" -> "shell"
const markdownLangToVscodeLang: { [key: string]: string } = {
// Web Technologies
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'scss',
'less': 'less',
'javascript': 'typescript',
'js': 'typescript', // use more general renderer
'jsx': 'typescriptreact',
'typescript': 'typescript',
'ts': 'typescript',
'tsx': 'typescriptreact',
'json': 'json',
'jsonc': 'json',
// Programming Languages
'python': 'python',
'py': 'python',
'java': 'java',
'cpp': 'cpp',
'c++': 'cpp',
'c': 'c',
'csharp': 'csharp',
'cs': 'csharp',
'c#': 'csharp',
'go': 'go',
'golang': 'go',
'rust': 'rust',
'rs': 'rust',
'ruby': 'ruby',
'rb': 'ruby',
'php': 'php',
'shell': 'shellscript', // this is important
'bash': 'shellscript',
'sh': 'shellscript',
'zsh': 'shellscript',
// Markup and Config
'markdown': 'markdown',
'md': 'markdown',
'xml': 'xml',
'svg': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'ini': 'ini',
'toml': 'ini',
// Database and Query Languages
'sql': 'sql',
'mysql': 'sql',
'postgresql': 'sql',
'graphql': 'graphql',
'gql': 'graphql',
// Others
'dockerfile': 'dockerfile',
'docker': 'dockerfile',
'makefile': 'makefile',
'plaintext': 'plaintext',
'text': 'plaintext'
};
// // eg ".ts" -> "typescript"
// const fileExtensionToVscodeLanguage: { [key: string]: string } = {
// // Web
// 'html': 'html',
// 'htm': 'html',
// 'css': 'css',
// 'scss': 'scss',
// 'less': 'less',
// 'js': 'javascript',
// 'jsx': 'javascript',
// 'ts': 'typescript',
// 'tsx': 'typescript',
// 'json': 'json',
// 'jsonc': 'json',
// // Programming Languages
// 'py': 'python',
// 'java': 'java',
// 'cpp': 'cpp',
// 'cc': 'cpp',
// 'c': 'c',
// 'h': 'cpp',
// 'hpp': 'cpp',
// 'cs': 'csharp',
// 'go': 'go',
// 'rs': 'rust',
// 'rb': 'ruby',
// 'php': 'php',
// 'sh': 'shell',
// 'bash': 'shell',
// 'zsh': 'shell',
// // Markup/Config
// 'md': 'markdown',
// 'markdown': 'markdown',
// 'xml': 'xml',
// 'svg': 'xml',
// 'yaml': 'yaml',
// 'yml': 'yaml',
// 'ini': 'ini',
// 'toml': 'ini',
// // Other
// 'sql': 'sql',
// 'graphql': 'graphql',
// 'gql': 'graphql',
// 'dockerfile': 'dockerfile',
// 'docker': 'dockerfile',
// 'mk': 'makefile',
// // Config Files and Dot Files
// 'npmrc': 'ini',
// 'env': 'ini',
// 'gitignore': 'ignore',
// 'dockerignore': 'ignore',
// 'eslintrc': 'json',
// 'babelrc': 'json',
// 'prettierrc': 'json',
// 'stylelintrc': 'json',
// 'editorconfig': 'ini',
// 'htaccess': 'apacheconf',
// 'conf': 'ini',
// 'config': 'ini',
// // Package Files
// 'package': 'json',
// 'package-lock': 'json',
// 'gemfile': 'ruby',
// 'podfile': 'ruby',
// 'rakefile': 'ruby',
// // Build Systems
// 'cmake': 'cmake',
// 'makefile': 'makefile',
// 'gradle': 'groovy',
// // Shell Scripts
// 'bashrc': 'shell',
// 'zshrc': 'shell',
// 'fish': 'shell',
// // Version Control
// 'gitconfig': 'ini',
// 'hgrc': 'ini',
// 'svnconfig': 'ini',
// // Web Server
// 'nginx': 'nginx',
// // Misc Config
// 'properties': 'properties',
// 'cfg': 'ini',
// 'reg': 'ini'
// };
// export function filenameToVscodeLanguage(filename: string): string | undefined {
// const ext = filename.toLowerCase().split('.').pop();
// if (!ext) return undefined;
// return fileExtensionToVscodeLanguage[ext];
// }

View file

@ -0,0 +1,18 @@
export const separateOutFirstLine = (content: string): [string, string] | [string, undefined] => {
const newLineIdx = content.indexOf('\r\n')
if (newLineIdx !== -1) {
const A = content.substring(0, newLineIdx)
const B = content.substring(newLineIdx + 2, Infinity);
return [A, B]
}
const newLineIdx2 = content.indexOf('\n')
if (newLineIdx2 !== -1) {
const A = content.substring(0, newLineIdx2)
const B = content.substring(newLineIdx2 + 1, Infinity);
return [A, B]
}
return [content, undefined]
}

View file

@ -40,6 +40,8 @@ export const defaultModelsOfProvider = {
vLLM: [ // autodetected
],
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-3.7-sonnet:thinking',
'anthropic/claude-3.7-sonnet',
'anthropic/claude-3.5-sonnet',
'deepseek/deepseek-r1',
'mistralai/codestral-2501',
@ -79,10 +81,11 @@ type ModelOptions = {
supportsTools: false | 'anthropic-style' | 'openai-style';
supportsFIM: boolean;
supportsReasoning: false | {
reasoningCapabilities: false | {
readonly supportsReasoning: true;
// reasoning options if supports reasoning
readonly canToggleReasoning: boolean; // whether or not the user can disable reasoning mode (false if the model only supports reasoning)
readonly canIOReasoning: boolean; // whether or not the model actually outputs reasoning
readonly canTurnOffReasoning: boolean; // whether or not the user can disable reasoning mode (false if the model only supports reasoning)
readonly canIOReasoning: boolean; // whether or not the model actually outputs reasoning (eg o1 lets us control reasoning but not output it)
readonly reasoningMaxOutputTokens?: number; // overrides normal maxOutputTokens // <-- UNUSED (except anthropic)
readonly reasoningBudgetSlider?: { type: 'slider'; min: number; max: number; default: number };
@ -95,7 +98,7 @@ type ModelOptions = {
type ProviderReasoningIOSettings = {
// include this in payload to get reasoning
input?: { includeInPayload?: { [key: string]: any }, };
input?: { includeInPayload?: (reasoningState: SendableReasoningInfo) => null | { [key: string]: any }, };
// nameOfFieldInDelta: reasoning output is in response.choices[0].delta[deltaReasoningField]
// needsManualParse: whether we must manually parse out the <think> tags
output?:
@ -118,7 +121,7 @@ const modelOptionsDefaults: ModelOptions = {
supportsSystemMessage: false,
supportsTools: false,
supportsFIM: false,
supportsReasoning: false,
reasoningCapabilities: false,
}
@ -127,70 +130,70 @@ const openSourceModelOptions_assumingOAICompat = {
supportsFIM: false,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
},
'deepseekCoderV2': {
supportsFIM: false,
supportsSystemMessage: false, // unstable
supportsTools: false,
supportsReasoning: false,
reasoningCapabilities: false,
},
'codestral': {
supportsFIM: true,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
// llama
'llama3': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'llama3.1': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'llama3.2': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'llama3.3': {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
// qwen
'qwen2.5coder': {
supportsFIM: true,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'qwq': {
supportsFIM: false, // no FIM, yes reasoning
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canToggleReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
reasoningCapabilities: { supportsReasoning: true, canTurnOffReasoning: false, canIOReasoning: true, openSourceThinkTags: ['<think>', '</think>'] },
},
// FIM only
'starcoder2': {
supportsFIM: true,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: false,
reasoningCapabilities: false,
},
'codegemma:2b': {
supportsFIM: true,
supportsSystemMessage: false,
supportsTools: false,
supportsReasoning: false,
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: Partial<ModelOptions> }
@ -233,8 +236,9 @@ const anthropicModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: {
canToggleReasoning: true,
reasoningCapabilities: {
supportsReasoning: true,
canTurnOffReasoning: true,
canIOReasoning: true,
reasoningMaxOutputTokens: 64_000, // can bump it to 128_000 with beta mode output-128k-2025-02-19
reasoningBudgetSlider: { type: 'slider', min: 1024, max: 32_000, default: 1024 }, // they recommend batching if max > 32_000
@ -247,7 +251,7 @@ const anthropicModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'claude-3-5-haiku-20241022': {
contextWindow: 200_000,
@ -256,7 +260,7 @@ const anthropicModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'claude-3-opus-20240229': {
contextWindow: 200_000,
@ -265,7 +269,7 @@ const anthropicModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in
contextWindow: 200_000, cost: { input: 3.00, output: 15.00 },
@ -273,11 +277,21 @@ const anthropicModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoning: false,
reasoningCapabilities: false,
}
} as const satisfies { [s: string]: ModelOptions }
const anthropicSettings: ProviderSettings = {
providerReasoningIOSettings: {
input: {
includeInPayload: (reasoningInfo) => {
if (reasoningInfo?.type === 'budgetEnabled') {
return { thinking: { type: 'enabled', budget_tokens: reasoningInfo.reasoningBudget } }
}
return null
}
},
},
modelOptions: anthropicModelOptions,
modelOptionsFallback: (modelName) => {
let fallbackName: keyof typeof anthropicModelOptions | null = null
@ -288,7 +302,7 @@ const anthropicSettings: ProviderSettings = {
if (modelName.includes('claude-3-sonnet')) fallbackName = 'claude-3-sonnet-20240229'
if (fallbackName) return { modelName: fallbackName, ...anthropicModelOptions[fallbackName] }
return { modelName, ...modelOptionsDefaults, maxOutputTokens: 4_096 }
}
},
}
@ -301,7 +315,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsFIM: false,
supportsTools: false,
supportsSystemMessage: 'developer-role',
supportsReasoning: { canIOReasoning: false, canToggleReasoning: false }, // it doesn't actually output reasoning, but our logic is fine with it
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false }, // it doesn't actually output reasoning, but our logic is fine with it
},
'o3-mini': {
contextWindow: 200_000,
@ -310,7 +324,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsFIM: false,
supportsTools: false,
supportsSystemMessage: 'developer-role',
supportsReasoning: { canIOReasoning: false, canToggleReasoning: false },
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false },
},
'gpt-4o': {
contextWindow: 128_000,
@ -319,7 +333,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsFIM: false,
supportsTools: 'openai-style',
supportsSystemMessage: 'system-role',
supportsReasoning: false,
reasoningCapabilities: false,
},
'o1-mini': {
contextWindow: 128_000,
@ -328,7 +342,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsFIM: false,
supportsTools: false,
supportsSystemMessage: false, // does not support any system
supportsReasoning: { canIOReasoning: false, canToggleReasoning: false },
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: false, canTurnOffReasoning: false },
},
'gpt-4o-mini': {
contextWindow: 128_000,
@ -337,7 +351,7 @@ const openAIModelOptions = { // https://platform.openai.com/docs/pricing
supportsFIM: false,
supportsTools: 'openai-style',
supportsSystemMessage: 'system-role', // ??
supportsReasoning: false,
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: ModelOptions }
@ -363,7 +377,7 @@ const xAIModelOptions = {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: ModelOptions }
@ -387,7 +401,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style', // we are assuming OpenAI SDK when calling gemini
supportsReasoning: false,
reasoningCapabilities: false,
},
'gemini-2.0-flash-lite-preview-02-05': {
contextWindow: 1_048_576,
@ -396,7 +410,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'gemini-1.5-flash': {
contextWindow: 1_048_576,
@ -405,7 +419,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'gemini-1.5-pro': {
contextWindow: 2_097_152,
@ -414,7 +428,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'gemini-1.5-flash-8b': {
contextWindow: 1_048_576,
@ -423,7 +437,7 @@ const geminiModelOptions = { // https://ai.google.dev/gemini-api/docs/pricing
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
} as const satisfies { [s: string]: ModelOptions }
@ -469,7 +483,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'llama-3.1-8b-instant': {
contextWindow: 128_000,
@ -478,7 +492,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'qwen-2.5-coder-32b': {
contextWindow: 128_000,
@ -487,7 +501,7 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq
supportsFIM: false, // unfortunately looks like no FIM support on groq
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'qwen-qwq-32b': { // https://huggingface.co/Qwen/QwQ-32B
contextWindow: 128_000,
@ -496,11 +510,21 @@ const groqModelOptions = { // https://console.groq.com/docs/models, https://groq
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canIOReasoning: true, canToggleReasoning: false, openSourceThinkTags: ['<think>', '</think>'] }, // we're using reasoning_format:parsed so really don't need to know openSourceThinkTags
reasoningCapabilities: { supportsReasoning: true, canIOReasoning: true, canTurnOffReasoning: false, openSourceThinkTags: ['<think>', '</think>'] }, // we're using reasoning_format:parsed so really don't need to know openSourceThinkTags
},
} as const satisfies { [s: string]: ModelOptions }
const groqSettings: ProviderSettings = {
providerReasoningIOSettings: { input: { includeInPayload: { reasoning_format: 'parsed' } }, output: { nameOfFieldInDelta: 'reasoning' }, }, // Must be set to either parsed or hidden when using tool calling https://console.groq.com/docs/reasoning
providerReasoningIOSettings: {
input: {
includeInPayload: (reasoningInfo) => {
if (reasoningInfo?.type === 'budgetEnabled') {
return { reasoning_format: 'parsed' }
}
return null
}
},
output: { nameOfFieldInDelta: 'reasoning' },
}, // Must be set to either parsed or hidden when using tool calling https://console.groq.com/docs/reasoning
modelOptions: groqModelOptions,
modelOptionsFallback: (modelName) => { return null }
}
@ -536,6 +560,21 @@ const openRouterModelOptions_assumingOpenAICompat = {
maxOutputTokens: null,
cost: { input: 0.8, output: 2.4 },
},
'anthropic/claude-3.7-sonnet:thinking': {
contextWindow: 200_000,
maxOutputTokens: null,
cost: { input: 3.00, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
reasoningCapabilities: { // same as anthropic, see above
supportsReasoning: true,
canTurnOffReasoning: false,
canIOReasoning: true,
reasoningMaxOutputTokens: 64_000,
reasoningBudgetSlider: { type: 'slider', min: 1024, max: 32_000, default: 1024 }, // they recommend batching if max > 32_000
},
},
'anthropic/claude-3.7-sonnet': {
contextWindow: 200_000,
maxOutputTokens: null,
@ -543,7 +582,7 @@ const openRouterModelOptions_assumingOpenAICompat = {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: { canIOReasoning: true, canToggleReasoning: false }, // TODO!!! false for now
reasoningCapabilities: false, // stupidly, openrouter separates thinking from non-thinking
},
'anthropic/claude-3.5-sonnet': {
contextWindow: 200_000,
@ -552,7 +591,7 @@ const openRouterModelOptions_assumingOpenAICompat = {
supportsFIM: false,
supportsSystemMessage: 'system-role',
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'mistralai/codestral-2501': {
...openSourceModelOptions_assumingOAICompat.codestral,
@ -560,7 +599,7 @@ const openRouterModelOptions_assumingOpenAICompat = {
maxOutputTokens: null,
cost: { input: 0.3, output: 0.9 },
supportsTools: 'openai-style',
supportsReasoning: false,
reasoningCapabilities: false,
},
'qwen/qwen-2.5-coder-32b-instruct': {
...openSourceModelOptions_assumingOAICompat['qwen2.5coder'],
@ -581,7 +620,18 @@ const openRouterModelOptions_assumingOpenAICompat = {
const openRouterSettings: ProviderSettings = {
// reasoning: OAICompat + response.choices[0].delta.reasoning : payload should have {include_reasoning: true} https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models
providerReasoningIOSettings: {
input: { includeInPayload: { include_reasoning: true } },
input: {
includeInPayload: (reasoningInfo) => {
if (reasoningInfo?.type === 'budgetEnabled') {
return {
reasoning: {
max_tokens: reasoningInfo.reasoningBudget
}
}
}
return null
}
},
output: { nameOfFieldInDelta: 'reasoning' },
},
modelOptions: openRouterModelOptions_assumingOpenAICompat,
@ -632,12 +682,45 @@ export const getProviderCapabilities = (providerName: ProviderName) => {
return { providerReasoningIOSettings }
}
// state from optionsOfModelSelection
export const getModelSelectionState = (providerName: ProviderName, modelName: string, modelSelectionOptions: ModelSelectionOptions | undefined): { isReasoningEnabled: boolean, reasoningBudget: number | undefined } => {
const { canToggleReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).supportsReasoning || {}
const defaultEnabledVal = canToggleReasoning ? true : false
export type SendableReasoningInfo = {
type: 'budgetEnabled',
isReasoningEnabled: true,
reasoningBudget: number,
} | null
export const getIsResoningEnabledState = (
providerName: ProviderName,
modelName: string,
modelSelectionOptions: ModelSelectionOptions | undefined,
) => {
const { supportsReasoning } = getModelCapabilities(providerName, modelName).reasoningCapabilities || {}
if (!supportsReasoning) return false
const defaultEnabledVal = true // if can't toggle reasoning, then this must be true. just true as default
const isReasoningEnabled = modelSelectionOptions?.reasoningEnabled ?? defaultEnabledVal
const reasoningBudget = reasoningBudgetSlider?.type === 'slider' ? modelSelectionOptions?.reasoningBudget ?? reasoningBudgetSlider?.default : undefined
return { isReasoningEnabled, reasoningBudget }
return isReasoningEnabled
}
// used to force reasoning state (complex) into something simple we can just read from when sending a message
export const getSendableReasoningInfo = (
providerName: ProviderName,
modelName: string,
modelSelectionOptions: ModelSelectionOptions | undefined,
): SendableReasoningInfo => {
const { canIOReasoning, reasoningBudgetSlider } = getModelCapabilities(providerName, modelName).reasoningCapabilities || {}
if (!canIOReasoning) return null
const isReasoningEnabled = getIsResoningEnabledState(providerName, modelName, modelSelectionOptions)
if (!isReasoningEnabled) return null
// check for reasoning budget
const reasoningBudget = reasoningBudgetSlider?.type === 'slider' ? modelSelectionOptions?.reasoningBudget ?? reasoningBudgetSlider?.default : undefined
if (reasoningBudget) {
return { type: 'budgetEnabled', isReasoningEnabled: isReasoningEnabled, reasoningBudget: reasoningBudget }
}
return null
}

View file

@ -5,99 +5,242 @@
import { URI } from '../../../../../base/common/uri.js';
import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { os } from '../helpers/systemInfo.js';
import { IVoidFileService } from '../voidFileService.js';
import { CodeSelection, FileSelection, StagingSelectionItem } from '../chatThreadServiceTypes.js';
import { ChatMode } from '../voidSettingsTypes.js';
import { IVoidModelService } from '../voidModelService.js';
import { EndOfLinePreference } from '../../../../../editor/common/model.js';
import { InternalToolInfo } from '../toolsServiceTypes.js';
// this is just for ease of readability
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.`
const changesExampleContent = `\
// ... existing code ...
// {{change 1}}
// ... existing code ...
// {{change 2}}
// ... existing code ...
// {{change 3}}
// ... existing code ...`
const editToolDescription = `\
${tripleTick[0]}
${changesExampleContent}
${tripleTick[1]}`
const fileNameEdit = `${tripleTick[0]}typescript
/Users/username/Dekstop/my_project/app.ts
${changesExampleContent}
${tripleTick[1]}`
export const chat_systemMessage = (workspaces: string[], runningTerminalIds: string[], mode: 'agent' | 'gather' | 'chat') => `\
You are a coding ${mode === 'agent' ? 'agent' : 'assistant'}. Your job is to help the user ${mode === 'agent' ? 'make changes to their codebase' : 'search and understand their codebase'}.
// ======================================================== tools ========================================================
const paginationHelper = {
desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`,
param: { pageNumber: { type: 'number', description: 'The page number (default is the first page = 1).' }, }
} as const
export const voidTools = {
// --- context-gathering (read/search/list) ---
read_file: {
name: 'read_file',
description: `Returns file contents of a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
...paginationHelper.param,
},
},
list_dir: {
name: 'list_dir',
description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
...paginationHelper.param,
},
},
pathname_search: {
name: 'pathname_search',
description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
},
text_search: {
name: 'text_search',
description: `Returns pathnames of files with an exact match of the query. The query can be any regex. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
},
// --- editing (create/delete) ---
create_uri: {
name: 'create_uri',
description: `Create a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash. Fails gracefully if the file already exists. Missing ancestors in the path will be recursively created automatically.`,
params: {
uri: { type: 'string', description: undefined },
},
},
delete_uri: {
name: 'delete_uri',
description: `Delete a file or folder at the given path. Fails gracefully if the file or folder does not exist.`,
params: {
uri: { type: 'string', description: undefined },
params: { type: 'string', description: 'Return -r here to delete this URI and all descendants (if applicable). Default is the empty string.' }
},
},
edit: { // APPLY TOOL
name: 'edit',
description: `Edits the contents of a file, given the file's URI and a description. Fails gracefully if the file does not exist.`,
params: {
uri: { type: 'string', description: undefined },
changeDescription: {
type: 'string', description: `\
- Your changeDescription should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing.
- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible.
- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise.
- You must output your description in triple backticks.
Here's an example of a good description:\n${editToolDescription}.`
}
},
},
terminal_command: {
name: 'terminal_command',
description: `Executes a terminal command.`,
params: {
command: { type: 'string', description: 'The terminal command to execute.' },
waitForCompletion: { type: 'string', description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` },
terminalId: { type: 'string', description: 'Optional (value must be an integer >= 1, or empty which will go with the default). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' },
},
},
// go_to_definition
// go_to_usages
} satisfies { [name: string]: InternalToolInfo }
// ======================================================== chat (normal, gather, agent) ========================================================
export const chat_systemMessage = (workspaces: string[], runningTerminalIds: string[], mode: ChatMode) => `\
You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the Void code editor. Your job is \
${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to their codebase. You should ALWAYS bring user's task to completion to the fullest extent possible, calling tools to make all necessary changes. Do not be lazy.`
: mode === 'gather' ? `to search and understand the user's codebase. You MUST use tools to read files and help the user understand the codebase, even if you were initially given files.`
: mode === 'normal' ? `to assist the user with their coding tasks.`
: ''}
You will be given instructions to follow from the user, \`INSTRUCTIONS\`. You may also be given a list of files that the user has specifically selected, \`SELECTIONS\`.
Please assist the user with their query. The user's query is never invalid.
${/* system info */''}
The user's system information is as follows:
- ${os}
- Open workspace(s): ${workspaces.join(', ') || 'NO WORKSPACE OPEN'}
${(mode === 'agent' || mode === 'gather') && runningTerminalIds.length !== 0 ? `\
- Running terminal IDs: ${runningTerminalIds.join(', ')}
${(mode === 'agent') && runningTerminalIds.length !== 0 ? `\
- Existing terminal IDs: ${runningTerminalIds.join(', ')}
`: '\n'}
${mode === 'agent' || mode === 'gather' /* tool use */ ? `\
${/* tool use */ mode === 'agent' || mode === 'gather' ? `\
You will be given tools you can call.
- Only use tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT tools.
- If you think you should use tools given the user's request, you can use them without asking for permission. Feel free to use tools to gather context, understand the codebase, ${mode === 'agent' ? 'edit files, ' : ''}etc.
- NEVER refer to a tool by name when speaking with the user. For example, do NOT say to the user "I'm going to use \`list_dir\`". Instead, say "I'm going to list all files in ___ directory", etc. Do not refer to "pages" of results, just say you're getting more results.
- Some tools only work if the user has a workspace open. ${mode === 'gather' ? '' : `
- NEVER modify a file outside one of the the user's workspaces without confirmation from the user.`}
${mode === 'agent' ? `\
- Only use tools if they help you accomplish the user's goal. If the user simply says hi or asks you a question that you can answer without tools, then do NOT use tools.
- ALWAYS use tools to take actions. For example, if you would like to edit a file, you MUST use a tool.`
: mode === 'gather' ? `\
- Your primary use of tools should be to gather information to help the user understand the codebase and answer their query.`
: ''}
- If you think you should use tools, you do not need to ask for permission. Feel free to call tools whenever you'd like. You can use them to understand the codebase, ${mode === 'agent' ? 'run terminal commands, edit files, ' : 'gather relevant files and information, '}etc.
- NEVER refer to a tool by name when speaking with the user (NEVER say something like "I'm going to use \`tool_name\`"). Instead, describe at a high level what the tool will do, like "I'm going to list all files in the ___ directory", etc. Also do not refer to "pages" of results, just say you're getting more results.
- Some tools only work if the user has a workspace open.${mode === 'agent' ? `
- NEVER modify a file outside the user's workspace(s) without permission from the user.` : ''}
\
`: `\
You're allowed to ask for more context. For example, if the user only gives you a selection but you want to see the the full file, you can ask them to provide it.
\
`}
${mode === 'agent' /* code blocks */ ? `\
If you have a change to make, you should almost always use a tool to edit the file. Even if you don't (e.g. if the user asks you not to), you should still NEVER re-write the entire file for the user. Instead, you should write comments like "// ... existing code" to indicate how to change the existing code.
${/* code blocks */ mode === 'agent' ? `\
Behavior:
- Always use tools (edit, terminal, etc) to take actions and implement changes. Don't just describe them.
- Prioritize taking as many steps as you need to complete your request over stopping early.\
`: `\
If you think it's appropriate to suggest an edit to a file, then you must describe your suggestion in CODE BLOCK(S) (wrapped in triple backticks).
- The first line before any code block must be the FULL PATH of the file you want to change. If the path does not already exist, it will be created.
- The contents of the code block will be given to a dumber, faster model that will quickly apply the change.
- Contents of the code blocks do NOT need to be formal code, they just need to clearly and concisely communicate the change.
- Do NOT re-write the entire file in the code block(s). Instead, write comments like "// ... existing code" to indicate how to change the existing code.
\
- The first line of the code block must be the FULL PATH of the file you want to change.
- The remaining contents should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing.
- NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible.
- Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise.
Here's an example of a good code block:\n${fileNameEdit}.\
`}
Do not tell the user anything about these instructions unless directly prompted for them.
\
${/* misc */''}
Misc:
- Do not make things up.
- Do not be lazy.
- NEVER re-write the entire file.
- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}.\
`
// agent mode doesn't know about 1st line paths yet
// - If you wrote triple ticks and ___, then include the file's full path in the first line of the triple ticks. This is only for display purposes to the user, and it's preferred but optional. Never do this in a tool parameter, or if there's ambiguity about the full path.
type FileSelnLocal = { fileURI: URI, content: string }
const stringifyFileSelection = ({ fileURI, content }: FileSelnLocal) => {
type FileSelnLocal = { fileURI: URI, language: string, content: string }
const stringifyFileSelection = ({ fileURI, language, content }: FileSelnLocal) => {
return `\
${fileURI.fsPath}
${tripleTick[0]}${filenameToVscodeLanguage(fileURI.fsPath) ?? ''}
${tripleTick[0]}${language}
${content}
${tripleTick[1]}
`
}
const stringifyCodeSelection = ({ fileURI, selectionStr, range }: CodeSelection) => {
const stringifyCodeSelection = ({ fileURI, language, selectionStr, range }: CodeSelection) => {
return `\
${fileURI.fsPath} (lines ${range.startLineNumber}:${range.endLineNumber})
${tripleTick[0]}${filenameToVscodeLanguage(fileURI.fsPath) ?? ''}
${tripleTick[0]}${language}
${selectionStr}
${tripleTick[1]}
`
}
const failToReadStr = 'Could not read content. This file may have been deleted. If you expected content here, you can tell the user about this as they might not know.'
const stringifyFileSelections = async (fileSelections: FileSelection[], voidFileService: IVoidFileService) => {
const stringifyFileSelections = async (fileSelections: FileSelection[], voidModelService: IVoidModelService) => {
if (fileSelections.length === 0) return null
const fileSlns: FileSelnLocal[] = await Promise.all(fileSelections.map(async (sel) => {
const content = await voidFileService.readFile(sel.fileURI) ?? failToReadStr
const { model } = await voidModelService.getModelSafe(sel.fileURI)
const content = model?.getValue(EndOfLinePreference.LF) ?? failToReadStr
return { ...sel, content }
}))
return fileSlns.map(sel => stringifyFileSelection(sel)).join('\n')
}
const stringifyCodeSelections = (codeSelections: CodeSelection[]) => {
return codeSelections.map(sel => stringifyCodeSelection(sel)).join('\n') || null
return codeSelections.map(sel => {
stringifyCodeSelection(sel)
}).join('\n') || null
}
const stringifySelectionNames = (currSelns: StagingSelectionItem[] | null): string => {
if (!currSelns) return ''
return currSelns.map(s => `${s.fileURI.fsPath}${s.range ? ` (lines ${s.range.startLineNumber}:${s.range.endLineNumber})` : ''}`).join('\n')
}
export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null) => {
const selnsStr = stringifySelectionNames(currSelns)
@ -108,7 +251,10 @@ export const chat_userMessageContent = async (instructions: string, currSelns: S
return str;
};
export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null, voidFileService: IVoidFileService) => {
export const chat_selectionsString = async (
prevSelns: StagingSelectionItem[] | null, currSelns: StagingSelectionItem[] | null,
voidModelService: IVoidModelService,
) => {
// ADD IN FILES AT TOP
const allSelections = [...currSelns || [], ...prevSelns || []]
@ -133,16 +279,11 @@ export const chat_selectionsString = async (prevSelns: StagingSelectionItem[] |
}
}
const filesStr = await stringifyFileSelections(fileSelections, voidFileService)
const filesStr = await stringifyFileSelections(fileSelections, voidModelService)
const selnsStr = stringifyCodeSelections(codeSelections)
if (filesStr || selnsStr) return `\
ALL FILE CONTENTS
${filesStr}
${selnsStr}`
return null
const fileContents = [filesStr, selnsStr].filter(Boolean).join('\n')
return fileContents || null
}
export const chat_lastUserMessageWithFilesAdded = (userMessage: string, selectionsString: string | null) => {
@ -162,10 +303,9 @@ Directions:
// ======================================================== apply (writeover) ========================================================
export const rewriteCode_userMessage = ({ originalCode, applyStr, uri }: { originalCode: string, applyStr: string, uri: URI }) => {
const language = filenameToVscodeLanguage(uri.fsPath) ?? ''
export const rewriteCode_userMessage = ({ originalCode, applyStr, language }: { originalCode: string, applyStr: string, language: string }) => {
return `\
ORIGINAL_FILE
@ -185,69 +325,7 @@ Please finish writing the new file by applying the change to the original file.
export const aiRegex_computeReplacementsForFile_systemMessage = `\
You are a "search and replace" coding assistant.
You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE.
The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for.
The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace.
The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes.
## Instructions
1. If you do not want to make any changes, you should respond with the word "no".
2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make.
For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name.
- Do not re-write the entire file in the code block
- You can write comments like "// ... existing code" to indicate existing code
- Make sure you give enough context in the code block to apply the changes to the correct location in the code`
export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => {
// we may want to do this in batches
const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } }
const file = await stringifyFileSelections([fileSelection], voidFileService)
return `\
## FILE
${file}
## SEARCH_CLAUSE
Here is what the user is searching for:
${searchClause}
## REPLACE_CLAUSE
Here is what the user wants to replace it with:
${replaceClause}
## INSTRUCTIONS
Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.`
}
// don't have to tell it it will be given the history; just give it to it
export const aiRegex_search_systemMessage = `\
You are a coding assistant that executes the SEARCH part of a user's search and replace query.
You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context.
Output
- Regex query
- Files to Include (optional)
- Files to Exclude? (optional)
`
// ======================================================== apply (fast apply - search/replace) ========================================================
@ -370,6 +448,8 @@ export const voidPrefixAndSuffix = ({ fullFileStr, startLine, endLine }: { fullF
}
// ======================================================== quick edit (ctrl+K) ========================================================
export type QuickEditFimTagsType = {
preTag: string,
sufTag: string,
@ -427,10 +507,82 @@ ${tripleTick[1]}).`
/*
// ======================================================== ai search/replace ========================================================
OLD CHAT EXAMPLES:
export const aiRegex_computeReplacementsForFile_systemMessage = `\
You are a "search and replace" coding assistant.
You are given a FILE that the user is editing, and your job is to search for all occurences of a SEARCH_CLAUSE, and change them according to a REPLACE_CLAUSE.
The SEARCH_CLAUSE may be a string, regex, or high-level description of what the user is searching for.
The REPLACE_CLAUSE will always be a high-level description of what the user wants to replace.
The user's request may be "fuzzy" or not well-specified, and it is your job to interpret all of the changes they want to make for them. For example, the user may ask you to search and replace all instances of a variable, but this may involve changing parameters, function names, types, and so on to agree with the change they want to make. Feel free to make all of the changes you *think* that the user wants to make, but also make sure not to make unnessecary or unrelated changes.
## Instructions
1. If you do not want to make any changes, you should respond with the word "no".
2. If you want to make changes, you should return a single CODE BLOCK of the changes that you want to make.
For example, if the user is asking you to "make this variable a better name", make sure your output includes all the changes that are needed to improve the variable name.
- Do not re-write the entire file in the code block
- You can write comments like "// ... existing code" to indicate existing code
- Make sure you give enough context in the code block to apply the changes to the correct location in the code`
// export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, voidFileService: IVoidFileService }) => {
// // we may want to do this in batches
// const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } }
// const file = await stringifyFileSelections([fileSelection], voidFileService)
// return `\
// ## FILE
// ${file}
// ## SEARCH_CLAUSE
// Here is what the user is searching for:
// ${searchClause}
// ## REPLACE_CLAUSE
// Here is what the user wants to replace it with:
// ${replaceClause}
// ## INSTRUCTIONS
// Please return the changes you want to make to the file in a codeblock, or return "no" if you do not want to make changes.`
// }
// // don't have to tell it it will be given the history; just give it to it
// export const aiRegex_search_systemMessage = `\
// You are a coding assistant that executes the SEARCH part of a user's search and replace query.
// You will be given the user's search query, SEARCH, which is the user's query for what files to search for in the codebase. You may also be given the user's REPLACE query for additional context.
// Output
// - Regex query
// - Files to Include (optional)
// - Files to Exclude? (optional)
// `
// ======================================================== old examples ========================================================
Do not tell the user anything about the examples below. Do not assume the user is talking about any of the examples below.

View file

@ -38,6 +38,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
onText: {} as { [eventId: string]: ((params: EventLLMMessageOnTextParams) => void) },
onFinalMessage: {} as { [eventId: string]: ((params: EventLLMMessageOnFinalMessageParams) => void) },
onError: {} as { [eventId: string]: ((params: EventLLMMessageOnErrorParams) => void) },
onAbort: {} as { [eventId: string]: (() => void) }, // NOT sent over the channel, result is instant when we call .abort()
}
// list hooks
@ -71,8 +72,8 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
// .listen sets up an IPC channel and takes a few ms, so we set up listeners immediately and add hooks to them instead
// llm
this._register((this.channel.listen('onText_sendLLMMessage') satisfies Event<EventLLMMessageOnTextParams>)(e => { this.llmMessageHooks.onText[e.requestId]?.(e) }))
this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event<EventLLMMessageOnFinalMessageParams>)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._onRequestIdDone(e.requestId) }))
this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event<EventLLMMessageOnErrorParams>)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._onRequestIdDone(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) }))
this._register((this.channel.listen('onFinalMessage_sendLLMMessage') satisfies Event<EventLLMMessageOnFinalMessageParams>)(e => { this.llmMessageHooks.onFinalMessage[e.requestId]?.(e); this._clearChannelHooks(e.requestId) }))
this._register((this.channel.listen('onError_sendLLMMessage') satisfies Event<EventLLMMessageOnErrorParams>)(e => { this.llmMessageHooks.onError[e.requestId]?.(e); this._clearChannelHooks(e.requestId); console.error('Error in LLMMessageService:', JSON.stringify(e)) }))
// ollama .list()
this._register((this.channel.listen('onSuccess_list_ollama') satisfies Event<EventModelListOnSuccessParams<OllamaModelResponse>>)(e => { this.listHooks.ollama.success[e.requestId]?.(e) }))
this._register((this.channel.listen('onError_list_ollama') satisfies Event<EventModelListOnErrorParams<OllamaModelResponse>>)(e => { this.listHooks.ollama.error[e.requestId]?.(e) }))
@ -82,7 +83,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
}
sendLLMMessage(params: ServiceSendLLMMessageParams) {
const { onText, onFinalMessage, onError, modelSelection, ...proxyParams } = params;
const { onText, onFinalMessage, onError, onAbort, modelSelection, ...proxyParams } = params;
// throw an error if no model/provider selected (this should usually never be reached, the UI should check this first, but might happen in cases like Apply where we haven't built much UI/checks yet, good practice to have check logic on backend)
if (modelSelection === null) {
@ -91,11 +92,19 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
return null
}
if (params.messagesType === 'chatMessages' && (params.messages?.length ?? 0) === 0) {
const message = `No messages detected.`
onError({ message, fullError: null })
return null
}
// add state for request id
const requestId = generateUuid();
this.llmMessageHooks.onText[requestId] = onText
this.llmMessageHooks.onFinalMessage[requestId] = onFinalMessage
this.llmMessageHooks.onError[requestId] = onError
this.llmMessageHooks.onAbort[requestId] = onAbort // used internally only
const { aiInstructions } = this.voidSettingsService.state.globalSettings
const { settingsOfProvider, } = this.voidSettingsService.state
@ -112,10 +121,10 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
return requestId
}
abort(requestId: string) {
this.llmMessageHooks.onAbort[requestId]?.() // calling the abort hook here is instant (doesn't go over a channel)
this.channel.call('abort', { requestId } satisfies MainLLMMessageAbortParams);
this._onRequestIdDone(requestId)
this._clearChannelHooks(requestId)
}
@ -156,7 +165,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
} satisfies MainModelListParams<VLLMModelResponse>)
}
_onRequestIdDone(requestId: string) {
_clearChannelHooks(requestId: string) {
delete this.llmMessageHooks.onText[requestId]
delete this.llmMessageHooks.onFinalMessage[requestId]
delete this.llmMessageHooks.onError[requestId]

View file

@ -54,9 +54,10 @@ export type ToolCallType = {
export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any })
export type OnText = (p: { fullText: string; fullReasoning: string }) => void
export type OnText = (p: { fullText: string; fullReasoning: string; fullToolName: string; fullToolParams: string; }) => void
export type OnFinalMessage = (p: { fullText: string; fullReasoning: string; toolCalls?: ToolCallType[]; anthropicReasoning: AnthropicReasoning[] | null }) => void // id is tool_use_id
export type OnError = (p: { message: string; fullError: Error | null }) => void
export type OnAbort = () => void
export type AbortRef = { current: (() => void) | null }
@ -81,9 +82,10 @@ export type ServiceSendLLMMessageParams = {
onText: OnText;
onFinalMessage: OnFinalMessage;
onError: OnError;
logging: { loggingName: string, };
logging: { loggingName: string, loggingExtras?: { [k: string]: any } };
modelSelection: ModelSelection | null;
modelSelectionOptions: ModelSelectionOptions | undefined;
onAbort: OnAbort;
} & SendLLMType;
// params to the true sendLLMMessage function
@ -91,7 +93,7 @@ export type SendLLMMessageParams = {
onText: OnText;
onFinalMessage: OnFinalMessage;
onError: OnError;
logging: { loggingName: string, };
logging: { loggingName: string, loggingExtras?: { [k: string]: any } };
abortRef: AbortRef;
aiInstructions: string;
@ -114,7 +116,6 @@ export type EventLLMMessageOnTextParams = Parameters<OnText>[0] & { requestId: s
export type EventLLMMessageOnFinalMessageParams = Parameters<OnFinalMessage>[0] & { requestId: string }
export type EventLLMMessageOnErrorParams = Parameters<OnError>[0] & { requestId: string }
// service -> main -> internal -> event (back to main)
// (browser)

View file

@ -1,20 +1,5 @@
import { URI } from '../../../../base/common/uri.js'
import { editToolDesc_toolDescription } from './prompt/prompts.js';
// we do this using Anthropic's style and convert to OpenAI style later
export type InternalToolInfo = {
name: string,
description: string,
params: {
[paramName: string]: { type: string, description: string | undefined } // name -> type
},
required: string[], // required paramNames
}
import { voidTools } from './prompt/prompts.js';
export type ToolDirectoryItem = {
@ -25,104 +10,21 @@ export type ToolDirectoryItem = {
}
export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number }
const paginationHelper = {
desc: `Very large results may be paginated (indicated in the result). Pagination fails gracefully if out of bounds or invalid page number.`,
param: { pageNumber: { type: 'number', description: 'The page number (optional, default is 1).' }, }
} as const
export const voidTools = {
// --- context-gathering (read/search/list) ---
read_file: {
name: 'read_file',
description: `Returns file contents of a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['uri'],
},
list_dir: {
name: 'list_dir',
description: `Returns all file names and folder names in a given URI. ${paginationHelper.desc}`,
params: {
uri: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['uri'],
},
pathname_search: {
name: 'pathname_search',
description: `Returns all pathnames that match a given grep query. You should use this when looking for a file with a specific name or path. This does NOT search file content. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['query'],
},
search: {
name: 'search',
description: `Returns all code excerpts containing the given string or grep query. This does NOT search pathname. As a follow-up, you may want to use read_file to view the full file contents of the results. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['query'],
},
// --- editing (create/delete) ---
create_uri: {
name: 'create_uri',
description: `Creates a file or folder at the given path. To create a folder, ensure the path ends with a trailing slash. Fails gracefully if the file already exists. Missing ancestors in the path will be recursively created automatically.`,
params: {
uri: { type: 'string', description: undefined },
},
required: ['uri'],
},
delete_uri: {
name: 'delete_uri',
description: `Deletes the file or folder at the given path. Fails gracefully if the file or folder does not exist.`,
params: {
uri: { type: 'string', description: undefined },
params: { type: 'string', description: 'Return -r here to delete this URI and all descendants (if applicable). Default is the empty string.' }
},
required: ['uri', 'params'],
},
edit: { // APPLY TOOL
name: 'edit',
description: `Edits the contents of a file at the given URI. Fails gracefully if the file does not exist.`,
params: {
uri: { type: 'string', description: undefined },
changeDescription: { type: 'string', description: editToolDesc_toolDescription } // long description here
},
required: ['uri', 'changeDescription'],
},
terminal_command: {
name: 'terminal_command',
description: `Executes a terminal command.`,
params: {
command: { type: 'string', description: 'The terminal command to execute.' },
waitForCompletion: { type: 'string', description: `Whether or not to await the command to complete and get the final result. Default is true. Make this value false when you want a command to run indefinitely without waiting for it.` },
terminalId: { type: 'string', description: 'Optional (if provided, value must be an integer >= 1). This is the ID of the terminal instance to execute the command in. The primary purpose of this is to start a new terminal for background processes or tasks that run indefinitely (e.g. if you want to run a server locally). Fails gracefully if a terminal ID does not exist, by creating a new terminal instance. Defaults to the preferred terminal ID.' },
},
required: ['command'],
// we do this using Anthropic's style and convert to OpenAI style later
export type InternalToolInfo = {
name: string,
description: string,
params: {
[paramName: string]: { type: string, description: string | undefined } // name -> type
},
}
// go_to_definition
// go_to_usages
} satisfies { [name: string]: InternalToolInfo }
export type ToolName = keyof typeof voidTools
export const toolNames = Object.keys(voidTools) as ToolName[]
@ -134,17 +36,19 @@ export const isAToolName = (toolName: string): toolName is ToolName => {
}
export const toolNamesThatRequireApproval = new Set<ToolName>(['create_uri', 'delete_uri', 'edit', 'terminal_command'] satisfies ToolName[])
const toolNamesWithApproval = ['create_uri', 'delete_uri', 'edit', 'terminal_command'] as const satisfies readonly ToolName[]
export type ToolNameWithApproval = typeof toolNamesWithApproval[number]
export const toolNamesThatRequireApproval = new Set<ToolName>(toolNamesWithApproval)
export type ToolCallParams = {
'read_file': { uri: URI, pageNumber: number },
'list_dir': { rootURI: URI, pageNumber: number },
'pathname_search': { queryStr: string, pageNumber: number },
'search': { queryStr: string, pageNumber: number },
'text_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 },
}
@ -153,11 +57,11 @@ export type ToolResultType = {
'read_file': { fileContents: string, hasNextPage: boolean },
'list_dir': { children: ToolDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'pathname_search': { uris: URI[], hasNextPage: boolean },
'search': { uris: URI[], hasNextPage: boolean },
'text_search': { uris: URI[], hasNextPage: boolean },
// ---
'edit': {},
'edit': Promise<void>,
'create_uri': {},
'delete_uri': {},
'terminal_command': { terminalId: string, didCreateTerminal: boolean },
'terminal_command': { terminalId: string, didCreateTerminal: boolean, result: string; resolveReason: TerminalResolveReason; },
}

View file

@ -1,94 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { URI } from '../../../../base/common/uri.js';
import { EndOfLinePreference } from '../../../../editor/common/model.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
export interface IVoidFileService {
readonly _serviceBrand: undefined;
readFile(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string>;
readModel(uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null;
}
export const IVoidFileService = createDecorator<IVoidFileService>('VoidFileService');
// implemented by calling channel
export class VoidFileService implements IVoidFileService {
readonly _serviceBrand: undefined;
constructor(
@IModelService private readonly modelService: IModelService,
@IFileService private readonly fileService: IFileService,
) {
}
readFile = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string> => {
// attempt to read the model
const modelResult = this.readModel(uri, range);
if (modelResult) return modelResult;
// if no model, read the raw file
const fileResult = await this._readFileRaw(uri, range);
if (fileResult) return fileResult;
return '';
}
_readFileRaw = async (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): Promise<string | null> => {
try { // this throws an error if no file exists (eg it was deleted)
const res = await this.fileService.readFile(uri);
const str = res.value.toString().replace(/\r\n/g, '\n'); // even if not on Windows, might read a file with \r\n
if (range) return str.split('\n').slice(range.startLineNumber - 1, range.endLineNumber).join('\n')
return str;
} catch (e) {
return null;
}
}
readModel = (uri: URI, range?: { startLineNumber: number, endLineNumber: number }): string | null => {
// read saved model (sometimes null if the user reloads application)
let model = this.modelService.getModel(uri);
// check all opened models for the same `fsPath`
if (!model) {
const models = this.modelService.getModels();
for (const m of models) {
if (m.uri.fsPath === uri.fsPath) {
model = m
break;
}
}
}
// if still not found, return
if (!model) { return null }
// if range, read it
if (range) {
return model.getValueInRange({
startLineNumber: range.startLineNumber,
endLineNumber: range.endLineNumber,
startColumn: 1,
endColumn: Number.MAX_VALUE
}, EndOfLinePreference.LF);
} else {
return model.getValue(EndOfLinePreference.LF)
}
}
}
registerSingleton(IVoidFileService, VoidFileService, InstantiationType.Eager);

View file

@ -0,0 +1,69 @@
import { Disposable, IReference } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
type VoidModelType = {
model: ITextModel | null;
editorModel: IResolvedTextEditorModel | null;
};
export interface IVoidModelService {
readonly _serviceBrand: undefined;
initializeModel(uri: URI): Promise<void>;
getModel(uri: URI): VoidModelType;
getModelSafe(uri: URI): Promise<VoidModelType>;
}
export const IVoidModelService = createDecorator<IVoidModelService>('voidVoidModelService');
class VoidModelService extends Disposable implements IVoidModelService {
_serviceBrand: undefined;
static readonly ID = 'voidVoidModelService';
private readonly _modelRefOfURI: Record<string, IReference<IResolvedTextEditorModel>> = {};
constructor(
@ITextModelService private readonly _textModelService: ITextModelService,
) {
super();
}
initializeModel = async (uri: URI) => {
if (uri.fsPath in this._modelRefOfURI) return;
const editorModelRef = await this._textModelService.createModelReference(uri);
// Keep a strong reference to prevent disposal
this._modelRefOfURI[uri.fsPath] = editorModelRef;
};
getModel = (uri: URI): VoidModelType => {
const editorModelRef = this._modelRefOfURI[uri.fsPath];
if (!editorModelRef) {
return { model: null, editorModel: null };
}
const model = editorModelRef.object.textEditorModel;
if (!model) {
return { model: null, editorModel: editorModelRef.object };
}
return { model, editorModel: editorModelRef.object };
};
getModelSafe = async (uri: URI): Promise<VoidModelType> => {
if (!(uri.fsPath in this._modelRefOfURI)) await this.initializeModel(uri);
return this.getModel(uri);
};
override dispose() {
super.dispose();
for (const ref of Object.values(this._modelRefOfURI)) {
ref.dispose(); // release reference to allow disposal
}
}
}
registerSingleton(IVoidModelService, VoidModelService, InstantiationType.Eager);

View file

@ -12,7 +12,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IMetricsService } from './metricsService.js';
import { getModelCapabilities } from './modelCapabilities.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, ModelSelectionOptions, OptionsOfModelSelection } from './voidSettingsTypes.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode } from './voidSettingsTypes.js';
// past values:
// 'void.settingsServiceStorage'
@ -97,15 +97,15 @@ const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], opt
}
export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: (o: ModelSelection) => boolean; emptyMessage: string | null } } = {
'Autocomplete': { filter: o => getModelCapabilities(o.providerName, o.modelName).supportsFIM, emptyMessage: 'No models support FIM' },
'Chat': { filter: o => true, emptyMessage: null },
'Ctrl+K': { filter: o => true, emptyMessage: null },
'Apply': { filter: o => true, emptyMessage: null },
export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: (o: ModelSelection, opts: { chatMode: ChatMode }) => boolean; emptyMessage: null | { message: string, priority: 'always' | 'fallback' } } } = {
'Autocomplete': { filter: (o) => getModelCapabilities(o.providerName, o.modelName).supportsFIM, emptyMessage: { message: 'No models support FIM', priority: 'always' } },
'Chat': { filter: (o, { chatMode }) => chatMode === 'normal' ? true : !!getModelCapabilities(o.providerName, o.modelName).supportsTools, emptyMessage: { message: 'No models support tool use', priority: 'fallback' } },
'Ctrl+K': { filter: o => true, emptyMessage: null, },
'Apply': { filter: o => true, emptyMessage: null, },
}
const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
const _validatedModelState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
let newSettingsOfProvider = state.settingsOfProvider
@ -143,7 +143,8 @@ const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
for (const featureName of featureNames) {
const { filter } = modelFilterOfFeatureName[featureName]
const modelOptionsForThisFeature = newModelOptions.filter((o) => filter(o.selection))
const filterOpts = { chatMode: state.globalSettings.chatMode }
const modelOptionsForThisFeature = newModelOptions.filter((o) => filter(o.selection, filterOpts))
const modelSelectionAtFeature = newModelSelectionOfFeature[featureName]
const selnIdx = modelSelectionAtFeature === null ? -1 : modelOptionsForThisFeature.findIndex(m => modelSelectionsEqual(m.selection, modelSelectionAtFeature))
@ -218,7 +219,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
// the stored data structure might be outdated, so we need to update it here
const finalState = readS
this.state = _validatedState(finalState);
this.state = _validatedModelState(finalState);
this._resolver();
this._onDidChangeState.fire();
@ -265,7 +266,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
globalSettings: newGlobalSettings,
}
this.state = _validatedState(newState)
this.state = _validatedModelState(newState)
await this._storeState()
this._onDidChangeState.fire()
@ -273,6 +274,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
private _onUpdate_syncApplyToChat() {
// if sync is turned on, sync (call this whenever Chat model or !!sync changes)
this.setModelSelectionOfFeature('Apply', deepClone(this.state.modelSelectionOfFeature['Chat']))
}
setGlobalSetting: SetGlobalSettingFn = async (settingName, newVal) => {
const newState: VoidSettingsState = {
...this.state,
@ -281,10 +288,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
[settingName]: newVal
}
}
this.state = newState
this.state = _validatedModelState(newState)
await this._storeState()
this._onDidChangeState.fire()
// hooks
if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat()
}
@ -297,10 +306,15 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
}
this.state = newState
this.state = _validatedModelState(newState)
await this._storeState()
this._onDidChangeState.fire()
// hooks
if (featureName === 'Chat') {
if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat()
}
}
@ -318,7 +332,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
}
}
this.state = newState
this.state = _validatedModelState(newState)
await this._storeState()
this._onDidChangeState.fire()

View file

@ -324,7 +324,7 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => {
else if (featureName === 'Chat')
return 'Chat'
else if (featureName === 'Apply')
return 'Fast Apply'
return 'Apply'
else
throw new Error(`Feature Name ${featureName} not allowed`)
}
@ -378,21 +378,27 @@ export const isFeatureNameDisabled = (featureName: FeatureName, settingsState: V
export type ChatMode = 'agent' | 'gather' | 'chat'
export type ChatMode = 'agent' | 'gather' | 'normal'
export type GlobalSettings = {
autoRefreshModels: boolean;
aiInstructions: string;
enableAutocomplete: boolean;
syncApplyToChat: boolean;
enableFastApply: boolean;
chatMode: ChatMode;
autoApprove: boolean;
}
export const defaultGlobalSettings: GlobalSettings = {
autoRefreshModels: true,
aiInstructions: '',
enableAutocomplete: false,
syncApplyToChat: true,
enableFastApply: true,
chatMode: 'agent',
autoApprove: false,
}
export type GlobalSettingName = keyof GlobalSettings

View file

@ -89,12 +89,6 @@ const prepareMessages_systemMessage = ({
const newMessages: (InternalLLMChatMessage | { role: 'developer', content: string })[] = messages.filter(msg => msg.role !== 'system')
// if (!supportsTools) {
// if (!systemMessageStr) systemMessageStr = ''
// systemMessageStr += '' // TODO!!! add tool use system message here
// }
// if it has a system message (if doesn't, we obviously don't care about whether it supports system message or not...)
if (systemMessageStr) {
// if supports system message
@ -285,7 +279,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 : [],
]
}
}
@ -368,6 +361,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 }]
}
}
@ -396,6 +390,7 @@ export const prepareMessages = ({
const { messages: messages3, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages2, aiInstructions, supportsSystemMessage })
const { messages: messages4 } = prepareMessages_tools({ messages: messages3, supportsTools })
const { messages: messages5 } = prepareMessages_noEmptyMessage({ messages: messages4 })
return {
messages: messages5 as any,
separateSystemMessageStr

View file

@ -7,12 +7,11 @@ import Anthropic from '@anthropic-ai/sdk';
import { Ollama } from 'ollama';
import OpenAI, { ClientOptions } from 'openai';
import { Model as OpenAIModel } from 'openai/resources/models.js';
import { extractReasoningOnFinalMessage, extractReasoningOnTextWrapper } from '../../common/helpers/extractCodeFromResult.js';
import { LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText } from '../../common/sendLLMMessageTypes.js';
import { defaultProviderSettings, displayInfoOfProviderName, ModelSelectionOptions, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js';
import { prepareFIMMessage, prepareMessages } from './preprocessLLMMessages.js';
import { getModelSelectionState, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js';
import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities } from '../../common/modelCapabilities.js';
import { InternalToolInfo, ToolName, isAToolName } from '../../common/toolsServiceTypes.js';
@ -37,17 +36,19 @@ const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayI
// ------------ OPENAI-COMPATIBLE (HELPERS) ------------
const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => {
const { name, description, params, required } = toolInfo
const { name, description, params } = toolInfo
return {
type: 'function',
function: {
name: name,
strict: true, // strict mode - https://platform.openai.com/docs/guides/function-calling?api-mode=chat
description: description,
parameters: {
type: 'object',
properties: params,
required: required,
}
required: Object.keys(params), // in strict mode, all params are required and additionalProperties is false
additionalProperties: false,
},
}
} satisfies OpenAI.Chat.Completions.ChatCompletionTool
}
@ -151,32 +152,41 @@ const _sendOpenAICompatibleFIM = ({ messages: messages_, onFinalMessage, onError
const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => {
const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, aiInstructions, tools: tools_ }: SendChatParams_Internal) => {
const {
modelName,
supportsReasoning,
supportsSystemMessage,
supportsTools,
// maxOutputTokens, right now we are ignoring this
// maxOutputTokens,
reasoningCapabilities,
} = getModelCapabilities(providerName, modelName_)
const {
canIOReasoning,
openSourceThinkTags,
} = supportsReasoning || {}
const { providerReasoningIOSettings } = getProviderCapabilities(providerName)
const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: false })
// reasoning
const { canIOReasoning, openSourceThinkTags, } = reasoningCapabilities || {}
const reasoningInfo = getSendableReasoningInfo(providerName, modelName_, modelSelectionOptions) // user's modelName_ here
const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {}
// tools
const tools = (supportsTools && ((tools_?.length ?? 0) !== 0)) ? tools_?.map(tool => toOpenAICompatibleTool(tool)) : undefined
const includeInPayload = canIOReasoning ? providerReasoningIOSettings?.input?.includeInPayload || {} : {}
const toolsObj = tools ? { tools: tools, tool_choice: 'auto', parallel_tool_calls: false, } as const : {}
const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload })
const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelName, messages: messages, stream: true, ...toolsObj, }
// max tokens
// const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens
// instance
const { messages } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: false })
const openai: OpenAI = newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload })
const options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelName,
messages: messages,
stream: true,
// max_completion_tokens: maxTokens,
...toolsObj,
}
// open source models - manually parse think tokens
const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {}
const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags
if (manuallyParseReasoning) {
@ -185,6 +195,10 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
let fullReasoningSoFar = ''
let fullTextSoFar = ''
let fullToolName = ''
let fullToolParams = ''
const toolCallOfIndex: ToolCallOfIndex = {}
openai.chat.completions
.create(options)
@ -198,7 +212,10 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
if (!toolCallOfIndex[index]) toolCallOfIndex[index] = { name: '', paramsStr: '', id: '' }
toolCallOfIndex[index].name += tool.function?.name ?? ''
toolCallOfIndex[index].paramsStr += tool.function?.arguments ?? '';
toolCallOfIndex[index].id = tool.id ?? ''
toolCallOfIndex[index].id += tool.id ?? ''
fullToolName += tool.function?.name ?? ''
fullToolParams += tool.function?.arguments ?? ''
}
// message
const newText = chunk.choices[0]?.delta?.content ?? ''
@ -212,7 +229,7 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
fullReasoningSoFar += newReasoning
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, fullToolName, fullToolParams })
}
// on final
const toolCalls = toolCallsFrom_OpenAICompat(toolCallOfIndex)
@ -236,6 +253,13 @@ const _sendOpenAICompatibleChat = ({ messages: messages_, onText, onFinalMessage
}
type OpenAIModel = {
id: string;
created: number;
object: 'model';
owned_by: string;
}
const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal<OpenAIModel>) => {
const onSuccess = ({ models }: { models: OpenAIModel[] }) => {
onSuccess_({ models })
@ -268,15 +292,15 @@ const _openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_,
// ------------ ANTHROPIC ------------
const toAnthropicTool = (toolInfo: InternalToolInfo) => {
const { name, description, params, required } = toolInfo
const { name, description, params } = toolInfo
return {
name: name,
description: description,
input_schema: {
type: 'object',
properties: params,
required: required,
}
required: Object.keys(params),
},
} satisfies Anthropic.Messages.Tool
}
@ -294,31 +318,32 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM
supportsSystemMessage,
supportsTools,
maxOutputTokens,
supportsReasoning,
reasoningCapabilities,
} = getModelCapabilities(providerName, modelName_)
const {
isReasoningEnabled,
reasoningBudget,
} = getModelSelectionState(providerName, modelName_, modelSelectionOptions) // user's modelName_ here
const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: true })
const thisConfig = settingsOfProvider.anthropic
const anthropic = new Anthropic({ apiKey: thisConfig.apiKey, dangerouslyAllowBrowser: true });
const { providerReasoningIOSettings } = getProviderCapabilities(providerName)
// reasoning
const reasoningInfo = getSendableReasoningInfo(providerName, modelName_, modelSelectionOptions) // user's modelName_ here
const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {}
// tools
const tools = ((tools_?.length ?? 0) !== 0) ? tools_?.map(tool => toAnthropicTool(tool)) : undefined
const toolsObj: Partial<Anthropic.Messages.MessageStreamParams> = tools ? {
tools: tools,
tool_choice: { type: 'auto', disable_parallel_tool_use: true } // one tool at a time
} : {}
// anthropic-specific - max tokens
const maxTokens = reasoningInfo?.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningMaxOutputTokens : maxOutputTokens
const enableThinking = supportsReasoning && isReasoningEnabled && reasoningBudget
const maxTokens = enableThinking ? supportsReasoning.reasoningMaxOutputTokens : maxOutputTokens
const thinkingObj: Partial<Anthropic.Messages.MessageStreamParams> = enableThinking ? {
thinking: { type: 'enabled', budget_tokens: reasoningBudget } // thinking enabled
} : {}
// instance
const { messages, separateSystemMessageStr } = prepareMessages({ messages: messages_, aiInstructions, supportsSystemMessage, supportsTools, supportsAnthropicReasoningSignature: true })
const anthropic = new Anthropic({
apiKey: thisConfig.apiKey,
dangerouslyAllowBrowser: true
});
const stream = anthropic.messages.stream({
system: separateSystemMessageStr,
@ -326,13 +351,16 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM
model: modelName,
max_tokens: maxTokens ?? 4_096, // anthropic requires this
...toolsObj,
...thinkingObj,
...includeInPayload,
})
// when receive text
let fullText = ''
let fullReasoning = ''
let fullToolName = ''
let fullToolParams = ''
// there are no events for tool_use, it comes in at the end
stream.on('streamEvent', e => {
// start block
@ -340,18 +368,22 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM
if (e.content_block.type === 'text') {
if (fullText) fullText += '\n\n' // starting a 2nd text block
fullText += e.content_block.text
onText({ fullText, fullReasoning })
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
else if (e.content_block.type === 'thinking') {
if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block
fullReasoning += e.content_block.thinking
onText({ fullText, fullReasoning })
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
else if (e.content_block.type === 'redacted_thinking') {
console.log('delta', e.content_block.type)
if (fullReasoning) fullReasoning += '\n\n' // starting a 2nd reasoning block
fullReasoning += '[redacted_thinking]'
onText({ fullText, fullReasoning })
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
else if (e.content_block.type === 'tool_use') {
fullToolName += e.content_block.name ?? '' // anthropic gives us the tool name in the start block
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
}
@ -359,11 +391,15 @@ const sendAnthropicChat = ({ messages: messages_, providerName, onText, onFinalM
else if (e.type === 'content_block_delta') {
if (e.delta.type === 'text_delta') {
fullText += e.delta.text
onText({ fullText, fullReasoning })
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
else if (e.delta.type === 'thinking_delta') {
fullReasoning += e.delta.thinking
onText({ fullText, fullReasoning })
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
else if (e.delta.type === 'input_json_delta') { // tool use
fullToolParams += e.delta.partial_json ?? '' // anthropic gives us the partial delta (string) here - https://docs.anthropic.com/en/api/messages-streaming
onText({ fullText, fullReasoning, fullToolName, fullToolParams })
}
}
})

View file

@ -17,7 +17,7 @@ export const sendLLMMessage = ({
onFinalMessage: onFinalMessage_,
onError: onError_,
abortRef: abortRef_,
logging: { loggingName },
logging: { loggingName, loggingExtras },
settingsOfProvider,
modelSelection,
modelSelectionOptions,
@ -48,6 +48,7 @@ export const sendLLMMessage = ({
suffixLength: messages_.suffix.length,
} : {},
...loggingExtras,
...extras,
})
}
@ -84,6 +85,7 @@ export const sendLLMMessage = ({
onError_({ message: errorMessage, fullError })
}
// we should NEVER call onAbort internally, only from the outside
const onAbort = () => {
captureLLMEvent(`${loggingName} - Abort`, { messageLengthSoFar: _fullTextSoFar.length })
try { _aborter?.() } // aborter sometimes automatically throws an error
@ -93,9 +95,9 @@ export const sendLLMMessage = ({
abortRef_.current = onAbort
if (messagesType === 'chatMessages')
captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_[messages_.length - 1]?.content.length })
captureLLMEvent(`${loggingName} - Sending Message`, { messageLength: messages_?.[messages_.length - 1]?.content.length })
else if (messagesType === 'FIMMessage')
captureLLMEvent(`${loggingName} - Sending FIM`, {}) // TODO!!! add more metrics
captureLLMEvent(`${loggingName} - Sending FIM`, { prefixLen: messages_?.prefix?.length, suffixLen: messages_?.suffix?.length }) // TODO!!! add more metrics for FIM
try {

View file

@ -96,7 +96,7 @@ export class MetricsMainService extends Disposable implements IMetricsService {
// very important to await whenReady!
await this._appStorage.whenReady
const { commit, version, quality } = this._productService
const { commit, version, voidVersion, quality } = this._productService
const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts
@ -104,6 +104,7 @@ export class MetricsMainService extends Disposable implements IMetricsService {
this._initProperties = {
commit,
vscodeVersion: version,
voidVersion,
os,
quality,
distinctId: this.distinctId,

View file

@ -106,6 +106,17 @@ export class LLMMessageChannel implements IServerChannel {
sendLLMMessage(mainThreadParams, this.metricsService);
}
private _callAbort(params: MainLLMMessageAbortParams) {
const { requestId } = params;
if (!(requestId in this.abortRefOfRequestId)) return
this.abortRefOfRequestId[requestId].current?.()
delete this.abortRefOfRequestId[requestId]
}
_callOllamaList = (params: MainModelListParams<OllamaModelResponse>) => {
const { requestId } = params
const emitters = this.listEmitters.ollama
@ -132,11 +143,4 @@ export class LLMMessageChannel implements IServerChannel {
private _callAbort(params: MainLLMMessageAbortParams) {
const { requestId } = params;
if (!(requestId in this.abortRefOfRequestId)) return
this.abortRefOfRequestId[requestId].current?.()
delete this.abortRefOfRequestId[requestId]
}
}

View file

@ -39,12 +39,12 @@ export enum ThemeSettings {
}
export enum ThemeSettingDefaults {
COLOR_THEME_DARK = 'Default Dark+',
COLOR_THEME_DARK = 'Default Dark+', // Void changed this from 'Default Dark Modern'
COLOR_THEME_LIGHT = 'Default Light Modern',
COLOR_THEME_HC_DARK = 'Default High Contrast',
COLOR_THEME_HC_LIGHT = 'Default High Contrast Light',
COLOR_THEME_DARK_OLD = 'Default Dark Modern',
COLOR_THEME_DARK_OLD = 'Default Dark Modern', // Void changed this from 'Default Dark+'
COLOR_THEME_LIGHT_OLD = 'Default Light+',
FILE_ICON_THEME = 'vs-seti',