Merge pull request #459 from voideditor/model-selection

Dump full files into context; better onboarding; better nav
This commit is contained in:
Andrew Pareles 2025-05-06 06:03:15 -07:00 committed by GitHub
commit 38eeee8e30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1485 additions and 1457 deletions

View file

@ -1,7 +1,7 @@
{
"nameShort": "Void",
"nameLong": "Void",
"voidVersion": "1.3.0",
"voidVersion": "1.3.2",
"applicationName": "void",
"dataFolderName": ".void-editor",
"win32MutexName": "voideditor",
@ -39,6 +39,7 @@
"linkProtectionTrustedDomains": [
"https://voideditor.com",
"https://voideditor.dev",
"https://github.com/voideditor/void"
"https://github.com/voideditor/void",
"https://ollama.com"
]
}

View file

@ -8,3 +8,19 @@ export const VOID_CTRL_K_ACTION_ID = 'void.ctrlKAction'
export const VOID_ACCEPT_DIFF_ACTION_ID = 'void.acceptDiff'
export const VOID_REJECT_DIFF_ACTION_ID = 'void.rejectDiff'
export const VOID_GOTO_NEXT_DIFF_ACTION_ID = 'void.goToNextDiff'
export const VOID_GOTO_PREV_DIFF_ACTION_ID = 'void.goToPrevDiff'
export const VOID_GOTO_NEXT_URI_ACTION_ID = 'void.goToNextUri'
export const VOID_GOTO_PREV_URI_ACTION_ID = 'void.goToPrevUri'
export const VOID_ACCEPT_FILE_ACTION_ID = 'void.acceptFile'
export const VOID_REJECT_FILE_ACTION_ID = 'void.rejectFile'
export const VOID_ACCEPT_ALL_DIFFS_ACTION_ID = 'void.acceptAllDiffs'
export const VOID_REJECT_ALL_DIFFS_ACTION_ID = 'void.rejectAllDiffs'

View file

@ -35,6 +35,8 @@ import { IConvertToLLMMessageService } from './convertToLLMMessageService.js';
import { timeout } from '../../../../base/common/async.js';
import { deepClone } from '../../../../base/common/objects.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IDirectoryStrService } from '../common/directoryStrService.js';
import { IFileService } from '../../../../platform/files/common/files.js';
// related to retrying when LLM message has error
@ -100,6 +102,13 @@ const defaultMessageState: UserMessageState = {
// a 'thread' means a chat message history
type WhenMounted = {
textAreaRef: { current: HTMLTextAreaElement | null }; // the textarea that this thread has, gets set in SidebarChat
scrollToBottom: () => void;
}
export type ThreadType = {
id: string; // store the id here too
createdAt: string; // ISO string
@ -120,6 +129,15 @@ export type ThreadType = {
[codespanName: string]: CodespanLocationLink
}
}
mountedInfo?: {
whenMounted: Promise<WhenMounted>
_whenMountedResolver: (res: WhenMounted) => void
mountedIsResolvedRef: { current: boolean };
}
};
}
@ -267,6 +285,9 @@ export interface IChatThreadService {
// jump to history
jumpToCheckpointBeforeMessageIdx(opts: { threadId: string, messageIdx: number, jumpToUserModified: boolean }): void;
focusCurrentChat: () => Promise<void>
blurCurrentChat: () => Promise<void>
}
export const IChatThreadService = createDecorator<IChatThreadService>('voidChatThreadService');
@ -300,6 +321,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
@INotificationService private readonly _notificationService: INotificationService,
@IConvertToLLMMessageService private readonly _convertToLLMMessagesService: IConvertToLLMMessageService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
@IDirectoryStrService private readonly _directoryStringService: IDirectoryStrService,
@IFileService private readonly _fileService: IFileService,
) {
super()
this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state
@ -333,6 +356,26 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
async focusCurrentChat() {
const threadId = this.state.currentThreadId
const thread = this.state.allThreads[threadId]
if (!thread) return
const s = await thread.state.mountedInfo?.whenMounted
if (!this.isCurrentlyFocusingMessage()) {
s?.textAreaRef.current?.focus()
}
}
async blurCurrentChat() {
const threadId = this.state.currentThreadId
const thread = this.state.allThreads[threadId]
if (!thread) return
const s = await thread.state.mountedInfo?.whenMounted
if (!this.isCurrentlyFocusingMessage()) {
s?.textAreaRef.current?.blur()
}
}
dangerousSetState = (newState: ThreadsState) => {
this.state = newState
@ -377,7 +420,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// this should be the only place this.state = ... appears besides constructor
private _setState(state: Partial<ThreadsState>, affectsCurrent: boolean) {
private _setState(state: Partial<ThreadsState>, doNotRefreshMountInfo?: boolean) {
const newState = {
...this.state,
...state
@ -385,8 +428,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
this.state = newState
if (affectsCurrent)
this._onDidChangeCurrentThread.fire()
this._onDidChangeCurrentThread.fire()
// if we just switched to a thread, update its current stream state if it's not streaming to possibly streaming
@ -408,6 +450,27 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
// if we did not just set the state to true, set mount info
if (doNotRefreshMountInfo) return
let whenMountedResolver: (w: WhenMounted) => void
const whenMountedPromise = new Promise<WhenMounted>((res) => whenMountedResolver = res)
this._setThreadState(threadId, {
mountedInfo: {
whenMounted: whenMountedPromise,
mountedIsResolvedRef: { current: false },
_whenMountedResolver: (w: WhenMounted) => {
whenMountedResolver(w)
const mountInfo = this.state.allThreads[threadId]?.state.mountedInfo
if (mountInfo) mountInfo.mountedIsResolvedRef.current = true
},
}
}, true) // do not trigger an update
}
@ -724,8 +787,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
this._setStreamState(threadId, { isRunning: 'LLM', llmInfo: { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }, interrupt: Promise.resolve(() => this._llmMessageService.abort(llmCancelToken)) })
const llmRes = await messageIsDonePromise // wait for message to complete
// if something else started running in the meantime
if (this.streamState[threadId]?.isRunning !== 'LLM') {
console.log('Unexpected chat agent state when', this.streamState[threadId]?.isRunning)
// console.log('Chat thread interrupted by a newer chat thread', this.streamState[threadId]?.isRunning)
return
}
@ -823,7 +888,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it)
this._setState({ allThreads: newThreads }) // the current thread just changed (it had a message added to it)
}
@ -1091,7 +1156,10 @@ We only need to do it for files that were edited since `from`, ie files between
class: undefined,
run: () => {
this.switchToThread(threadId)
// TODO!!! scroll to bottom
// scroll to bottom
this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => {
m.scrollToBottom()
})
}
}]
},
@ -1125,14 +1193,12 @@ We only need to do it for files that were edited since `from`, ie files between
this._addUserCheckpoint({ threadId })
}
const { chatMode } = this._settingsService.state.globalSettings
// add user's message to chat history
const instructions = userMessage
const currSelns: StagingSelectionItem[] = _chatSelections ?? thread.state.stagingSelections
const opts = chatMode !== 'normal' ? { type: 'references' } as const : { type: 'fullCode', voidModelService: this._voidModelService } as const
const userMessageContent = await chat_userMessageContent(instructions, currSelns, opts) // user message + names of files (NOT content)
const userMessageContent = await chat_userMessageContent(instructions, currSelns, { directoryStrService: this._directoryStringService, fileService: this._fileService }) // user message + names of files (NOT content)
const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
this._addMessageToThread(threadId, userHistoryElt)
@ -1142,6 +1208,11 @@ We only need to do it for files that were edited since `from`, ie files between
this._runChatAgent({ threadId, ...this._currentModelSelectionProps(), }),
threadId,
)
// scroll to bottom
this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => {
m.scrollToBottom()
})
}
@ -1164,7 +1235,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
};
this._storeAllThreads(newThreads);
this._setState({ allThreads: newThreads }, true);
this._setState({ allThreads: newThreads });
}
// Now call the original method to add the user message and stream the response
@ -1194,7 +1265,7 @@ We only need to do it for files that were edited since `from`, ie files between
messages: slicedMessages
}
}
}, true)
})
// re-add the message and stream it
this._addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, threadId })
@ -1467,7 +1538,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}, true)
})
}
@ -1498,7 +1569,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
switchToThread(threadId: string) {
this._setState({ currentThreadId: threadId }, true)
this._setState({ currentThreadId: threadId })
}
@ -1521,7 +1592,7 @@ We only need to do it for files that were edited since `from`, ie files between
[newThread.id]: newThread
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads, currentThreadId: newThread.id }, true)
this._setState({ allThreads: newThreads, currentThreadId: newThread.id })
}
@ -1534,7 +1605,7 @@ We only need to do it for files that were edited since `from`, ie files between
// store the updated threads
this._storeAllThreads(newThreads);
this._setState({ ...this.state, allThreads: newThreads }, true)
this._setState({ ...this.state, allThreads: newThreads })
}
duplicateThread(threadId: string) {
@ -1550,7 +1621,7 @@ We only need to do it for files that were edited since `from`, ie files between
[newThread.id]: newThread,
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true)
this._setState({ allThreads: newThreads })
}
@ -1571,7 +1642,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it)
this._setState({ allThreads: newThreads }) // the current thread just changed (it had a message added to it)
}
// sets the currently selected message (must be undefined if no message is selected)
@ -1592,7 +1663,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}
}, true)
})
// // when change focused message idx, jump - do not jump back when click edit, too confusing.
// if (messageIdx !== undefined)
@ -1680,12 +1751,12 @@ We only need to do it for files that were edited since `from`, ie files between
)
}
}
}, true)
})
}
// set thread.state
private _setThreadState(threadId: string, state: Partial<ThreadType['state']>): void {
private _setThreadState(threadId: string, state: Partial<ThreadType['state']>, doNotRefreshMountInfo?: boolean): void {
const thread = this.state.allThreads[threadId]
if (!thread) return
@ -1700,7 +1771,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}
}, true)
}, doNotRefreshMountInfo)
}

View file

@ -11,7 +11,7 @@ import { reParsedToolXMLString, chat_systemMessage, ToolName } from '../common/p
import { AnthropicLLMChatMessage, AnthropicReasoning, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, OpenAILLMChatMessage, RawToolParamsObj } from '../common/sendLLMMessageTypes.js';
import { IVoidSettingsService } from '../common/voidSettingsService.js';
import { ChatMode, FeatureName, ModelSelection, ProviderName } from '../common/voidSettingsTypes.js';
import { IDirectoryStrService } from './directoryStrService.js';
import { IDirectoryStrService } from '../common/directoryStrService.js';
import { ITerminalToolService } from './terminalToolService.js';
import { IVoidModelService } from '../common/voidModelService.js';
import { URI } from '../../../../base/common/uri.js';
@ -38,7 +38,7 @@ type SimpleLLMMessage = {
const EMPTY_MESSAGE = '(empty message)'
const CHARS_PER_TOKEN = 4
const CHARS_PER_TOKEN = 4 // assume abysmal chars per token
const TRIM_TO_LEN = 120
@ -271,7 +271,10 @@ const prepareOpenAIOrAnthropicMessages = ({
reservedOutputTokenSpace: number | null | undefined,
}): { messages: AnthropicOrOpenAILLMMessage[], separateSystemMessage: string | undefined } => {
reservedOutputTokenSpace = reservedOutputTokenSpace ?? 4_096 // default to 4096
reservedOutputTokenSpace = Math.max(
contextWindow * 1 / 2, // reserve at least 1/4 of the token window length
reservedOutputTokenSpace ?? 4_096 // defaults to 4096
)
let messages: (SimpleLLMMessage | { role: 'system', content: string })[] = deepClone(messages_)
// ================ system message ================
@ -337,7 +340,7 @@ const prepareOpenAIOrAnthropicMessages = ({
for (const m of messages) { totalLen += m.content.length }
const charsNeedToTrim = totalLen - Math.max(
(contextWindow - reservedOutputTokenSpace) * CHARS_PER_TOKEN, // can be 0, in which case charsNeedToTrim=everything, bad
4_096 // ensure we don't trim at least 4096 chars (just a random small value)
5_000 // ensure we don't trim at least 5k chars (just a random small value)
)
@ -359,6 +362,7 @@ const prepareOpenAIOrAnthropicMessages = ({
// if can finish here, do
const numCharsWillTrim = m.content.length - TRIM_TO_LEN
if (numCharsWillTrim > remainingCharsToTrim) {
// trim remainingCharsToTrim + '...'.length chars
m.content = m.content.slice(0, m.content.length - remainingCharsToTrim - '...'.length).trim() + '...'
break
}
@ -498,19 +502,15 @@ const prepareMessages = (params: {
providerName: ProviderName
}): { messages: LLMChatMessage[], separateSystemMessage: string | undefined } => {
const specialFormat = params.specialToolFormat // this is just for ts idiocy
if (params.providerName === 'gemini') {
// treat as anthropic style, then convert to gemini style
const specialFormat = params.specialToolFormat // this is just for ts stupidness
// if need to convert to gemini style of messaes, do that (treat as anthropic style, then convert to gemini style)
if (params.providerName === 'gemini' || specialFormat === 'gemini-style') {
const res = prepareOpenAIOrAnthropicMessages({ ...params, specialToolFormat: specialFormat === 'gemini-style' ? 'anthropic-style' : undefined })
const messages = res.messages as AnthropicLLMChatMessage[]
const messages2 = prepareGeminiMessages(messages)
return { messages: messages2, separateSystemMessage: res.separateSystemMessage }
}
else {
if (specialFormat === 'gemini-style') {
throw new Error(`Tried preparing messages with tool format ${params.specialToolFormat} but the provider was ${params.providerName}, not Gemini.`)
}
}
return prepareOpenAIOrAnthropicMessages({ ...params, specialToolFormat: specialFormat })
}
@ -585,6 +585,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess
`...Directories string cut off, use tools to read more...`
: `...Directories string cut off, ask user for more if necessary...`
})
const includeXMLToolDefinitions = !specialToolFormat
const persistentTerminalIDs = this.terminalToolService.listPersistentTerminalIds()

View file

@ -269,7 +269,11 @@ class EditCodeService extends Disposable implements IEditCodeService {
}
public processRawKeybindingText(keybindingStr: string): string {
return keybindingStr
.replace(/Enter/g, '↵') // ⏎
.replace(/Backspace/g, '⌫');
}
// private _notifyError = (e: Parameters<OnError>[0]) => {
// const details = errorDetails(e.fullError)
@ -2255,13 +2259,6 @@ registerSingleton(IEditCodeService, EditCodeService, InstantiationType.Eager);
const processRawKeybindingText = (keybindingStr: string) => {
return keybindingStr
.replace(/Enter/g, '↵') // ⏎
.replace(/Backspace/g, '⌫')
}
class AcceptRejectInlineWidget extends Widget implements IOverlayWidget {
public getId(): string {
@ -2289,7 +2286,8 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget {
offsetLines: number
},
@IVoidCommandBarService private readonly _voidCommandBarService: IVoidCommandBarService,
@IKeybindingService private readonly _keybindingService: IKeybindingService
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IEditCodeService private readonly _editCodeService: IEditCodeService,
) {
super();
@ -2313,8 +2311,10 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget {
const acceptKeybinding = this._keybindingService.lookupKeybinding(VOID_ACCEPT_DIFF_ACTION_ID);
const rejectKeybinding = this._keybindingService.lookupKeybinding(VOID_REJECT_DIFF_ACTION_ID);
const acceptKeybindLabel = processRawKeybindingText(acceptKeybinding && acceptKeybinding.getLabel() || '');
const rejectKeybindLabel = processRawKeybindingText(rejectKeybinding && rejectKeybinding.getLabel() || '')
// Use the standalone function directly since we're in a nested class that
// can't access EditCodeService's methods
const acceptKeybindLabel = this._editCodeService.processRawKeybindingText(acceptKeybinding && acceptKeybinding.getLabel() || '');
const rejectKeybindLabel = this._editCodeService.processRawKeybindingText(rejectKeybinding && rejectKeybinding.getLabel() || '');
const commandBarStateAtUri = this._voidCommandBarService.stateOfURI[uri.fsPath];
const selectedDiffIdx = commandBarStateAtUri?.diffIdx ?? 0; // 0th item is selected by default

View file

@ -42,6 +42,8 @@ export const IEditCodeService = createDecorator<IEditCodeService>('editCodeServi
export interface IEditCodeService {
readonly _serviceBrand: undefined;
processRawKeybindingText(keybindingStr: string): string;
callBeforeStartApplying(opts: CallBeforeStartApplyingOpts): Promise<void>;
startApplying(opts: StartApplyingOpts): [URI, Promise<void>] | null;
instantlyApplySearchReplaceBlocks(opts: { uri: URI; searchReplaceBlocks: string }): void;

View file

@ -158,7 +158,6 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
}
return <Codespan
// text={link?.displayText || text}
text={link?.displayText || text}
onClick={onClick}
className={link ? 'underline hover:brightness-90 transition-all duration-200 cursor-pointer' : ''}

View file

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import React, { useEffect, useState } from 'react'
import { useIsDark, useSidebarState } from '../util/services.js'
import { useIsDark } from '../util/services.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { QuickEditChat } from './QuickEditChat.js'
import { QuickEditPropsType } from '../../../quickEditActions.js'

View file

@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { useIsDark, useSidebarState } from '../util/services.js';
import { useIsDark } from '../util/services.js';
// import { SidebarThreadSelector } from './SidebarThreadSelector.js';
// import { SidebarChat } from './SidebarChat.js';
@ -12,8 +12,6 @@ import { SidebarChat } from './SidebarChat.js';
import ErrorBoundary from './ErrorBoundary.js';
export const Sidebar = ({ className }: { className: string }) => {
const sidebarState = useSidebarState()
const { currentTab: tab } = sidebarState
const isDark = useIsDark()
return <div
@ -29,34 +27,12 @@ export const Sidebar = ({ className }: { className: string }) => {
`}
>
{/* <span onClick={() => {
const tabs = ['chat', 'settings', 'threadSelector']
const index = tabs.indexOf(tab)
sidebarStateService.setState({ currentTab: tabs[(index + 1) % tabs.length] as any })
}}>clickme {tab}</span> */}
{/* <div className={`w-full h-auto mb-2 ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow z-10`}>
<ErrorBoundary>
<SidebarThreadSelector />
</ErrorBoundary>
</div> */}
<div className={`w-full h-full ${tab === 'chat' ? '' : 'hidden'}`}>
<div className={`w-full h-full`}>
<ErrorBoundary>
<SidebarChat />
</ErrorBoundary>
{/* <ErrorBoundary>
<ModelSelectionSettings />
</ErrorBoundary> */}
</div>
{/* <div className={`w-full h-full ${tab === 'settings' ? '' : 'hidden'}`}>
<ErrorBoundary>
<VoidProviderSettings />
</ErrorBoundary>
</div> */}
</div>
</div>

View file

@ -6,7 +6,7 @@
import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState, useFullChatThreadsStreamState } from '../util/services.js';
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState, useFullChatThreadsStreamState } from '../util/services.js';
import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markdown/ChatMarkdownRender.js';
import { URI } from '../../../../../../../base/common/uri.js';
@ -14,7 +14,7 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ErrorDisplay } from './ErrorDisplay.js';
import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch } from '../util/inputs.js';
import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js';
import { OldSidebarThreadSelector, PastThreadsList } from './SidebarThreadSelector.js';
import { PastThreadsList } from './SidebarThreadSelector.js';
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
import { ChatMode, displayInfoOfProviderName, FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
@ -950,7 +950,6 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
// global state
let isBeingEdited = false
@ -1046,7 +1045,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr
} catch (e) {
console.error('Error while editing message:', e)
}
sidebarStateService.fireFocusChat()
await chatThreadsService.focusCurrentChat()
requestAnimationFrame(() => _scrollToBottom?.())
}
@ -1553,8 +1552,8 @@ export const ToolChildrenWrapper = ({ children, className }: { children: React.R
</div>
</div>
}
export const CodeChildren = ({ children }: { children: React.ReactNode }) => {
return <div className='bg-void-bg-3 p-1 rounded-sm overflow-auto text-sm'>
export const CodeChildren = ({ children, className }: { children: React.ReactNode, className?: string }) => {
return <div className={`${className ?? ''} p-1 rounded-sm overflow-auto text-sm`}>
<div className='!select-text cursor-auto'>
{children}
</div>
@ -1643,7 +1642,7 @@ const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: strin
const componentParams: ToolHeaderParams = { title, desc1, isError, icon }
componentParams.children = <ToolChildrenWrapper>
<CodeChildren>
<CodeChildren className='bg-void-bg-3'>
{message}
</CodeChildren>
</ToolChildrenWrapper>
@ -2043,7 +2042,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
componentParams.numResults = result.lines.length;
componentParams.children = result.lines.length === 0 ? undefined :
<ToolChildrenWrapper>
<CodeChildren>
<CodeChildren className='bg-void-bg-3'>
<pre className='font-mono whitespace-pre'>
{toolsService.stringOfResult['search_in_file'](params, result)}
</pre>
@ -2177,7 +2176,7 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
else if (toolMessage.type === 'tool_error') {
const { result } = toolMessage
if (params) { componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } }
componentParams.children = componentParams.bottomChildren = <BottomChildren title='Error'>
componentParams.bottomChildren = <BottomChildren title='Error'>
<CodeChildren>
{result}
</CodeChildren>
@ -2420,53 +2419,6 @@ const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, me
}
export const AcceptAllButtonWrapper = ({ text, onClick, className }: { text: string, onClick: () => void, className?: string }) => (
<button
className={`
px-1 py-0.5
flex items-center gap-1
text-white text-[11px] text-nowrap
rounded-md
cursor-pointer
${className}
`}
style={{
backgroundColor: acceptAllBg,
border: acceptBorder,
}}
type='button'
onClick={onClick}
>
{text ? <span>{text}</span> : <Check size={16} />}
</button>
)
export const RejectAllButtonWrapper = ({ text, onClick, className }: { text: string, onClick: () => void, className?: string }) => (
<button
className={`
px-1 py-0.5
flex items-center gap-1
text-white text-[11px] text-nowrap
rounded-md
cursor-pointer
${className}
`}
style={{
backgroundColor: rejectAllBg,
border: rejectBorder,
}}
type='button'
onClick={onClick}
>
{text ? <span>{text}</span> : <X size={16} />}
</button>
)
const CommandBarInChat = () => {
const { stateOfURI: commandBarStateOfURI, sortedURIs: sortedCommandBarURIs } = useCommandBarState()
const numFilesChanged = sortedCommandBarURIs.length
@ -2771,18 +2723,6 @@ export const SidebarChat = () => {
const settingsState = useSettingsState()
// ----- HIGHER STATE -----
// sidebar state
const sidebarStateService = accessor.get('ISidebarStateService')
useEffect(() => {
const disposables: IDisposable[] = []
disposables.push(
sidebarStateService.onDidFocusChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.focus() }),
sidebarStateService.onDidBlurChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.blur() })
)
return () => disposables.forEach(d => d.dispose())
}, [sidebarStateService, textAreaRef])
const { isHistoryOpen } = useSidebarState()
// threads state
const chatThreadsState = useChatThreadsState()
@ -2841,16 +2781,24 @@ export const SidebarChat = () => {
const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_L_ACTION_ID)?.getLabel()
// scroll to top on thread switch
useEffect(() => {
if (isHistoryOpen)
scrollContainerRef.current?.scrollTo({ top: 0, left: 0 })
}, [isHistoryOpen, currentThread.id])
const threadId = currentThread.id
const currCheckpointIdx = chatThreadsState.allThreads[threadId]?.state?.currCheckpointIdx ?? undefined // if not exist, treat like checkpoint is last message (infinity)
// resolve mount info
const isResolved = chatThreadsState.allThreads[threadId]?.state.mountedInfo?.mountedIsResolvedRef.current
useEffect(() => {
if (isResolved) return
chatThreadsState.allThreads[threadId]?.state.mountedInfo?._whenMountedResolver?.({
textAreaRef: textAreaRef,
scrollToBottom: () => scrollToBottom(scrollContainerRef),
})
}, [chatThreadsState, threadId, textAreaRef, scrollContainerRef, isResolved])
const previousMessagesHTML = useMemo(() => {
// const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint')
// tool request shows up as Editing... if in progress
@ -3020,7 +2968,7 @@ export const SidebarChat = () => {
{landingPageInput}
</ErrorBoundary>
{Object.keys(chatThreadsState.allThreads).length > 1 ? // show if there are threads
{Object.keys(chatThreadsState.allThreads).length > 1 ? // show if there are threads
<ErrorBoundary>
<div className='pt-8 mb-2 text-void-fg-3 text-root select-none pointer-events-none'>Previous Threads</div>
<PastThreadsList />

View file

@ -11,138 +11,6 @@ import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserChe
import { IsRunningType, ThreadType } from '../../../chatThreadService.js';
export const OldSidebarThreadSelector = () => {
const accessor = useAccessor()
const sidebarStateService = accessor.get('ISidebarStateService')
return (
<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 */}
<h2 className='font-bold text-lg'>{`History`}</h2>
{/* X button at top right */}
<button
type='button'
className='absolute top-0 right-0'
onClick={() => sidebarStateService.setState({ isHistoryOpen: false })}
>
<IconX
size={16}
className="p-[1px] stroke-[2] opacity-80 text-void-fg-3 hover:brightness-95"
/>
</button>
</div>
{/* a list of all the past threads */}
{/* <OldPastThreadsList /> */}
</div>
)
}
const truncate = (s: string) => {
let len = s.length
const TRUNC_AFTER = 16
if (len >= TRUNC_AFTER)
s = s.substring(0, TRUNC_AFTER) + '...'
return s
}
const OldPastThreadsList = () => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
const threadsState = useChatThreadsState()
const { allThreads } = threadsState
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0)
return <div className="px-1">
<ul className="flex flex-col gap-y-0.5 overflow-y-auto list-disc">
{sortedThreadIds.length === 0
? <div key="nothreads" className="text-center text-void-fg-3 brightness-90 text-root">{`There are no chat threads yet.`}</div>
: sortedThreadIds.map((threadId) => {
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 === 'user');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx]
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
// const secondMsgIdx = pastThread.messages.findIndex(
// (msg, i) => msg.role !== 'system' && !!msg.displayContent && i > firstMsgIdx
// );
// if (secondMsgIdx !== -1) {
// secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? '');
// }
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
return (
<li key={pastThread.id}>
<button
type='button'
className={`
hover:bg-void-bg-1
${threadsState.currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''}
rounded-sm px-2 py-1
w-full
text-left
flex items-center
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false })
}}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>
</button>
</li>
);
})
}
</ul>
</div>
}
const numInitialThreads = 3
export const PastThreadsList = ({ className = '' }: { className?: string }) => {
@ -313,7 +181,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
// const settingsState = useSettingsState()
// const convertService = accessor.get('IConvertToLLMMessageService')
@ -369,7 +236,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false });
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}

View file

@ -249,7 +249,6 @@ const getOptionsAtPath = async (accessor: ReturnType<typeof useAccessor>, path:
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = i === 0 ? `/${pathParts[i]}` : `${currentPath}/${pathParts[i]}`;
console.log('filepath', currentPath);
// Create a proper directory URI
const directoryUri = URI.joinPath(
@ -383,8 +382,21 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
// Focus the textarea first
textarea.focus();
// Insert the @ to mention text in the editor (we decided not to do this for now)
// document.execCommand('insertText', false, text + ' '); // add space after too
// delete the @ and set the cursor position
// Get cursor position
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
// Get the text before the cursor, excluding the @ symbol that triggered the menu
const textBeforeCursor = textarea.value.substring(0, startPos - 1);
const textAfterCursor = textarea.value.substring(endPos);
// Replace the text including the @ symbol with the selected option
textarea.value = textBeforeCursor + textAfterCursor;
// Set cursor position after the inserted text
const newCursorPos = textBeforeCursor.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
// React's onChange relies on a SyntheticEvent system
// The best way to ensure it runs is to call callbacks directly
@ -407,17 +419,21 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
setIsMenuOpen(false)
insertTextAtCursor(option.abbreviatedName)
const newSelection: StagingSelectionItem = option.leafNodeType === 'File' ? {
let newSelection: StagingSelectionItem
if (option.leafNodeType === 'File') newSelection = {
type: 'File',
uri: option.uri,
language: languageService.guessLanguageIdByFilepathOrFirstLine(option.uri) || '',
state: { wasAddedAsCurrentFile: false }
} : option.leafNodeType === 'Folder' ? {
state: { wasAddedAsCurrentFile: false },
}
else if (option.leafNodeType === 'Folder') newSelection = {
type: 'Folder',
uri: option.uri,
language: undefined,
state: undefined,
} : (undefined as never)
}
else throw new Error(`Unexpected leafNodeType ${option.leafNodeType}`)
chatThreadService.addNewStagingSelection(newSelection)
console.log('selected', option.uri?.fsPath)
}
@ -855,15 +871,44 @@ export const VoidSimpleInputBox = ({ value, onChangeValue, placeholder, classNam
compact?: boolean;
passwordBlur?: boolean;
} & React.InputHTMLAttributes<HTMLInputElement>) => {
// Create a ref for the input element to maintain the same DOM node between renders
const inputRef = useRef<HTMLInputElement>(null);
// Track if we need to restore selection
const selectionRef = useRef<{ start: number | null, end: number | null }>({
start: null,
end: null
});
// Handle value changes without recreating the input
useEffect(() => {
const input = inputRef.current;
if (input && input.value !== value) {
// Store current selection positions
selectionRef.current.start = input.selectionStart;
selectionRef.current.end = input.selectionEnd;
// Update the value
input.value = value;
// Restore selection if we had it before
if (selectionRef.current.start !== null && selectionRef.current.end !== null) {
input.setSelectionRange(selectionRef.current.start, selectionRef.current.end);
}
}
}, [value]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChangeValue(e.target.value);
}, [onChangeValue]);
return (
<input
value={value}
onChange={(e) => onChangeValue(e.target.value)}
ref={inputRef}
defaultValue={value} // Use defaultValue instead of value to avoid recreation
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
// className='max-w-44 w-full border border-void-border-2 bg-void-bg-1 text-void-fg-3 text-root'
// className={`w-full resize-none text-void-fg-1 placeholder:text-void-fg-3 px-2 py-1 rounded-sm
className={`w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1
${compact ? 'py-1 px-2' : 'py-2 px-4 '}
rounded

View file

@ -6,7 +6,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'
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 { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'
@ -23,7 +22,6 @@ 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 { ISidebarStateService } from '../../../sidebarStateService.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'
import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'
@ -58,9 +56,6 @@ import { ISearchService } from '../../../../../../services/search/common/search.
// 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 sidebarState: VoidSidebarState
const sidebarStateListeners: Set<(s: VoidSidebarState) => void> = new Set()
let chatThreadsState: ThreadsState
const chatThreadsStateListeners: Set<(s: ThreadsState) => void> = new Set()
@ -91,7 +86,6 @@ export const _registerServices = (accessor: ServicesAccessor) => {
_registerAccessor(accessor)
const stateServices = {
sidebarStateService: accessor.get(ISidebarStateService),
chatThreadsStateService: accessor.get(IChatThreadService),
settingsStateService: accessor.get(IVoidSettingsService),
refreshModelService: accessor.get(IRefreshModelService),
@ -101,15 +95,10 @@ export const _registerServices = (accessor: ServicesAccessor) => {
modelService: accessor.get(IModelService),
}
const { sidebarStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices
const { settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices
sidebarState = sidebarStateService.state
disposables.push(
sidebarStateService.onDidChangeState(() => {
sidebarState = sidebarStateService.state
sidebarStateListeners.forEach(l => l(sidebarState))
})
)
chatThreadsState = chatThreadsStateService.state
disposables.push(
@ -193,7 +182,6 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IRefreshModelService: accessor.get(IRefreshModelService),
IVoidSettingsService: accessor.get(IVoidSettingsService),
IEditCodeService: accessor.get(IEditCodeService),
ISidebarStateService: accessor.get(ISidebarStateService),
IChatThreadService: accessor.get(IChatThreadService),
IInstantiationService: accessor.get(IInstantiationService),
@ -250,16 +238,6 @@ export const useAccessor = () => {
// -- state of services --
export const useSidebarState = () => {
const [s, ss] = useState(sidebarState)
useEffect(() => {
ss(sidebarState)
sidebarStateListeners.add(ss)
return () => { sidebarStateListeners.delete(ss) }
}, [ss])
return s
}
export const useSettingsState = () => {
const [s, ss] = useState(settingsState)
useEffect(() => {

View file

@ -9,9 +9,19 @@ 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, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js';
import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js';
import { VoidCommandBarProps } from '../../../voidCommandBarService.js';
import { AcceptAllButtonWrapper, RejectAllButtonWrapper } from '../sidebar-tsx/SidebarChat.js';
import { Check, EllipsisVertical, Menu, MoveDown, MoveLeft, MoveRight, MoveUp, X } from 'lucide-react';
import {
VOID_GOTO_NEXT_DIFF_ACTION_ID,
VOID_GOTO_PREV_DIFF_ACTION_ID,
VOID_GOTO_NEXT_URI_ACTION_ID,
VOID_GOTO_PREV_URI_ACTION_ID,
VOID_ACCEPT_FILE_ACTION_ID,
VOID_REJECT_FILE_ACTION_ID,
VOID_ACCEPT_ALL_DIFFS_ACTION_ID,
VOID_REJECT_ALL_DIFFS_ACTION_ID
} from '../../../actionIDs.js';
export const VoidCommandBarMain = ({ uri, editor }: VoidCommandBarProps) => {
const isDark = useIsDark()
@ -25,14 +35,55 @@ export const VoidCommandBarMain = ({ uri, editor }: VoidCommandBarProps) => {
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
}
export const AcceptAllButtonWrapper = ({ text, onClick, className, ...props }: { text: string, onClick: () => void, className?: string } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
className={`
px-2 py-0.5
flex items-center gap-1
text-white text-[11px] text-nowrap
h-full rounded-none
cursor-pointer
${className}
`}
style={{
backgroundColor: 'var(--vscode-button-background)',
color: 'var(--vscode-button-foreground)',
border: 'none',
}}
type='button'
onClick={onClick}
{...props}
>
{text ? <span>{text}</span> : <Check size={16} />}
</button>
)
export const RejectAllButtonWrapper = ({ text, onClick, className, ...props }: { text: string, onClick: () => void, className?: string } & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
className={`
px-2 py-0.5
flex items-center gap-1
text-white text-[11px] text-nowrap
h-full rounded-none
cursor-pointer
${className}
`}
style={{
backgroundColor: 'var(--vscode-button-secondaryBackground)',
color: 'var(--vscode-button-secondaryForeground)',
border: 'none',
}}
type='button'
onClick={onClick}
{...props}
>
{text ? <span>{text}</span> : <X size={16} />}
</button>
)
const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
export const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const editorService = accessor.get('ICodeEditorService')
@ -40,12 +91,9 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const commandService = accessor.get('ICommandService')
const commandBarService = accessor.get('IVoidCommandBarService')
const voidModelService = accessor.get('IVoidModelService')
const keybindingService = accessor.get('IKeybindingService')
const { stateOfURI: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState()
// useEffect(() => {
// console.log('MOUNTING!!!')
// }, [])
const [showAcceptRejectAllButtons, setShowAcceptRejectAllButtons] = useState(false)
// latestUriIdx is used to remember place in leftRight
const _latestValidUriIdxRef = useRef<number | null>(null)
@ -70,56 +118,18 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const s = commandBarService.stateOfURI[uri.fsPath]
if (!s) return
const { diffIdx } = s
goToDiffIdx(diffIdx ?? 0)
commandBarService.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 - 1, 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)
}
}
// Using service methods directly
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
@ -127,191 +137,247 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
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 nextDiffIdx = commandBarService.getNextDiffIdx(1)
const prevDiffIdx = commandBarService.getNextDiffIdx(-1)
const nextURIIdx = commandBarService.getNextUriIdx(1)
const prevURIIdx = commandBarService.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>
const leftRightDisabled = prevURIIdx === null || nextURIIdx === null
// accept/reject if current URI has changes
const onAcceptAll = () => {
const onAcceptFile = () => {
if (!uri) return
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false, _addToHistory: true })
metricsService.capture('Accept All', {})
metricsService.capture('Accept File', {})
}
const onRejectAll = () => {
const onRejectFile = () => {
if (!uri) return
editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false, _addToHistory: true })
metricsService.capture('Reject All', {})
metricsService.capture('Reject File', {})
}
const onAcceptAll = () => {
commandBarService.acceptOrRejectAllFiles({ behavior: 'accept' });
metricsService.capture('Accept All', {})
setShowAcceptRejectAllButtons(false);
}
const onRejectAll = () => {
commandBarService.acceptOrRejectAllFiles({ behavior: 'reject' });
metricsService.capture('Reject All', {})
setShowAcceptRejectAllButtons(false);
}
const _upKeybinding = keybindingService.lookupKeybinding(VOID_GOTO_PREV_DIFF_ACTION_ID);
const _downKeybinding = keybindingService.lookupKeybinding(VOID_GOTO_NEXT_DIFF_ACTION_ID);
const _leftKeybinding = keybindingService.lookupKeybinding(VOID_GOTO_PREV_URI_ACTION_ID);
const _rightKeybinding = keybindingService.lookupKeybinding(VOID_GOTO_NEXT_URI_ACTION_ID);
const _acceptFileKeybinding = keybindingService.lookupKeybinding(VOID_ACCEPT_FILE_ACTION_ID);
const _rejectFileKeybinding = keybindingService.lookupKeybinding(VOID_REJECT_FILE_ACTION_ID);
const _acceptAllKeybinding = keybindingService.lookupKeybinding(VOID_ACCEPT_ALL_DIFFS_ACTION_ID);
const _rejectAllKeybinding = keybindingService.lookupKeybinding(VOID_REJECT_ALL_DIFFS_ACTION_ID);
const upKeybindLabel = editCodeService.processRawKeybindingText(_upKeybinding?.getLabel() || '');
const downKeybindLabel = editCodeService.processRawKeybindingText(_downKeybinding?.getLabel() || '');
const leftKeybindLabel = editCodeService.processRawKeybindingText(_leftKeybinding?.getLabel() || '');
const rightKeybindLabel = editCodeService.processRawKeybindingText(_rightKeybinding?.getLabel() || '');
const acceptFileKeybindLabel = editCodeService.processRawKeybindingText(_acceptFileKeybinding?.getAriaLabel() || '');
const rejectFileKeybindLabel = editCodeService.processRawKeybindingText(_rejectFileKeybinding?.getAriaLabel() || '');
const acceptAllKeybindLabel = editCodeService.processRawKeybindingText(_acceptAllKeybinding?.getAriaLabel() || '');
const rejectAllKeybindLabel = editCodeService.processRawKeybindingText(_rejectAllKeybinding?.getAriaLabel() || '');
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: rejectBg,
// border: rejectBorder,
// color: 'white',
// fontSize: buttonFontSize,
// padding: '2px 4px',
// borderRadius: '6px',
// cursor: 'pointer'
// }}
// >
// Reject File
// </button>
const acceptAllButton = <AcceptAllButtonWrapper
text={'Keep Changes'}
onClick={onAcceptAll}
/>
const rejectAllButton = <RejectAllButtonWrapper
text={'Reject All'}
onClick={onRejectAll}
/>
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' : ''}`}>
{upButton}
{downButton}
<span className="min-w-16 px-2 text-xs leading-[1]">
{isADiffInThisFile ?
`Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}`
: streamState === 'streaming' ?
'No changes yet'
: `No changes`
}
</span>
// For pages without a current file index, show a simplified command bar
if (currFileIdx === null) {
return (
<div className="pointer-events-auto">
<div className="flex bg-void-bg-2 shadow-md border border-void-border-2 [&>*:first-child]:pl-3 [&>*:last-child]:pr-3 [&>*]:border-r [&>*]:border-void-border-2 [&>*:last-child]:border-r-0">
<div className="flex items-center px-3">
<span className="text-xs whitespace-nowrap">
{`${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed`}
</span>
</div>
<button
className="text-xs whitespace-nowrap cursor-pointer flex items-center justify-center gap-1 bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)] hover:opacity-90 h-full px-3"
onClick={() => commandBarService.goToURIIdx(nextURIIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commandBarService.goToURIIdx(nextURIIdx);
}
}}
>
Next <MoveRight className='size-3 my-1' />
</button>
</div>
</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 leading-[1]">
{currFileIdx !== null ?
`File ${currFileIdx + 1} of ${sortedCommandBarURIs.length}`
: `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed`
}
</span>
return (
<div className="pointer-events-auto">
{/* Accept All / Reject All buttons that appear when the vertical ellipsis is clicked */}
{showAcceptRejectAllButtons && showAcceptRejectAll && (
<div className="flex justify-end mb-1">
<div className="inline-flex bg-void-bg-2 rounded shadow-md border border-void-border-2 overflow-hidden">
<div className="flex items-center [&>*]:border-r [&>*]:border-void-border-2 [&>*:last-child]:border-r-0">
<AcceptAllButtonWrapper
// text={`Accept All${acceptAllKeybindLabel ? ` ${acceptAllKeybindLabel}` : ''}`}
text={`Accept All`}
data-tooltip-id='void-tooltip'
data-tooltip-content={acceptAllKeybindLabel}
data-tooltip-delay-show={500}
onClick={onAcceptAll}
/>
<RejectAllButtonWrapper
// text={`Reject All${rejectAllKeybindLabel ? ` ${rejectAllKeybindLabel}` : ''}`}
text={`Reject All`}
data-tooltip-id='void-tooltip'
data-tooltip-content={rejectAllKeybindLabel}
data-tooltip-delay-show={500}
onClick={onRejectAll}
/>
</div>
</div>
</div>
)}
<div className="flex items-center bg-void-bg-2 rounded shadow-md border border-void-border-2 [&>*:first-child]:pl-3 [&>*:last-child]:pr-3 [&>*]:px-3 [&>*]:border-r [&>*]:border-void-border-2 [&>*:last-child]:border-r-0">
{/* Diff Navigation Group */}
<div className="flex items-center py-0.5">
<button
className="cursor-pointer"
disabled={upDownDisabled}
onClick={() => commandBarService.goToDiffIdx(prevDiffIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commandBarService.goToDiffIdx(prevDiffIdx);
}
}}
data-tooltip-id="void-tooltip"
data-tooltip-content={`${upKeybindLabel ? `${upKeybindLabel}` : ''}`}
data-tooltip-delay-show={500}
>
<MoveUp className='size-3 transition-opacity duration-200 opacity-70 hover:opacity-100' />
</button>
<span className={`text-xs whitespace-nowrap px-1 ${!isADiffInThisFile ? 'opacity-70' : ''}`}>
{isADiffInThisFile
? `Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}`
: streamState === 'streaming'
? 'No changes yet'
: 'No changes'
}
</span>
<button
className="cursor-pointer"
disabled={upDownDisabled}
onClick={() => commandBarService.goToDiffIdx(nextDiffIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commandBarService.goToDiffIdx(nextDiffIdx);
}
}}
data-tooltip-id="void-tooltip"
data-tooltip-content={`${downKeybindLabel ? `${downKeybindLabel}` : ''}`}
data-tooltip-delay-show={500}
>
<MoveDown className='size-3 transition-opacity duration-200 opacity-70 hover:opacity-100' />
</button>
</div>
{/* File Navigation Group */}
<div className="flex items-center py-0.5">
<button
className="cursor-pointer"
disabled={leftRightDisabled}
onClick={() => commandBarService.goToURIIdx(prevURIIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commandBarService.goToURIIdx(prevURIIdx);
}
}}
data-tooltip-id="void-tooltip"
data-tooltip-content={`${leftKeybindLabel ? `${leftKeybindLabel}` : ''}`}
data-tooltip-delay-show={500}
>
<MoveLeft className='size-3 transition-opacity duration-200 opacity-70 hover:opacity-100' />
</button>
<span className="text-xs whitespace-nowrap px-1 mx-0.5">
{currFileIdx !== null
? `File ${currFileIdx + 1} of ${sortedCommandBarURIs.length}`
: `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'}`
}
</span>
<button
className="cursor-pointer"
disabled={leftRightDisabled}
onClick={() => commandBarService.goToURIIdx(nextURIIdx)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commandBarService.goToURIIdx(nextURIIdx);
}
}}
data-tooltip-id="void-tooltip"
data-tooltip-content={`${rightKeybindLabel ? `${rightKeybindLabel}` : ''}`}
data-tooltip-delay-show={500}
>
<MoveRight className='size-3 transition-opacity duration-200 opacity-70 hover:opacity-100' />
</button>
</div>
{/* Accept/Reject buttons - only shown when appropriate */}
{showAcceptRejectAll && (
<div className='flex self-stretch gap-0 !px-0 !py-0'>
<AcceptAllButtonWrapper
// text={`Accept File${acceptFileKeybindLabel ? ` ${acceptFileKeybindLabel}` : ''}`}
text={`Accept File`}
data-tooltip-id='void-tooltip'
data-tooltip-content={acceptFileKeybindLabel}
data-tooltip-delay-show={500}
onClick={onAcceptFile}
/>
<RejectAllButtonWrapper
// text={`Reject File${rejectFileKeybindLabel ? ` ${rejectFileKeybindLabel}` : ''}`}
text={`Reject File`}
data-tooltip-id='void-tooltip'
data-tooltip-content={rejectFileKeybindLabel}
data-tooltip-delay-show={500}
onClick={onRejectFile}
/>
</div>
)}
{/* Triple colon menu button */}
{showAcceptRejectAll && <div className='!px-0 !py-0 self-stretch flex justify-center items-center'>
<div
className="cursor-pointer px-1 self-stretch flex justify-center items-center"
onClick={() => setShowAcceptRejectAllButtons(!showAcceptRejectAllButtons)}
>
<EllipsisVertical
className="size-3"
/>
</div>
</div>}
</div>
</div>
</div>
return <div className={`flex flex-col items-center gap-y-2 pointer-events-auto`}>
{showAcceptRejectAll && acceptRejectAllButtons}
{leftRightUpDownButtons}
</div>
)
}

View file

@ -6,10 +6,9 @@
import { useEffect, useRef, useState } from 'react';
import { useAccessor, useIsDark, useSettingsState } from '../util/services.js';
import { Brain, Check, ChevronRight, DollarSign, ExternalLink, Lock, X } from 'lucide-react';
import { displayInfoOfProviderName, ProviderName, providerNames, refreshableProviderNames } from '../../../../common/voidSettingsTypes.js';
import { getModelCapabilities, ollamaRecommendedModels } from '../../../../common/modelCapabilities.js';
import { displayInfoOfProviderName, ProviderName, providerNames, localProviderNames, featureNames, FeatureName, isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js';
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
import { AddModelInputBox, AnimatedCheckmarkButton, OllamaSetupInstructions, OneClickSwitchButton, SettingsForProvider } from '../void-settings-tsx/Settings.js';
import { OllamaSetupInstructions, OneClickSwitchButton, SettingsForProvider, ModelDump } from '../void-settings-tsx/Settings.js';
import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js';
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js';
import { isLinux } from '../../../../../../../base/common/platform.js';
@ -27,9 +26,10 @@ export const VoidOnboarding = () => {
<div className={`@@void-scope ${isDark ? 'dark' : ''}`}>
<div
className={`
bg-void-bg-3 fixed top-0 right-0 bottom-0 left-0 width-full h-full z-[99999]
bg-void-bg-3 fixed top-0 right-0 bottom-0 left-0 width-full z-[99999]
transition-all duration-1000 ${isOnboardingComplete ? 'opacity-0 pointer-events-none' : 'opacity-100 pointer-events-auto'}
`}
style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<ErrorBoundary>
<VoidOnboardingContent />
@ -90,6 +90,180 @@ const FadeIn = ({ children, className, delayMs = 0, durationMs, ...props }: { ch
}
// Onboarding
// =============================================
// New AddProvidersPage Component and helpers
// =============================================
const tabNames = ['Free', 'Paid', 'Local'] as const;
type TabName = typeof tabNames[number] | 'Cloud/Other';
// Data for cloud providers tab
const cloudProviders: ProviderName[] = ['googleVertex', 'liteLLM', 'microsoftAzure', 'openAICompatible'];
// Data structures for provider tabs
const providerNamesOfTab: Record<TabName, ProviderName[]> = {
Free: ['gemini', 'openRouter'],
Local: localProviderNames,
Paid: providerNames.filter(pn => !(['gemini', 'openRouter', ...localProviderNames, ...cloudProviders] as string[]).includes(pn)) as ProviderName[],
'Cloud/Other': cloudProviders,
};
const descriptionOfTab: Record<TabName, string> = {
Free: `Providers with a 100% free tier. Add as many as you'd like!`,
Paid: `Connect directly with any provider (bring your own key).`,
Local: `Add as many local providers as you'd like! Active providers should appear automatically.`,
'Cloud/Other': `Reach out for custom configuration requests.`,
};
const featureNameMap: { display: string, featureName: FeatureName }[] = [
{ display: 'Chat', featureName: 'Chat' },
{ display: 'Quick Edit', featureName: 'Ctrl+K' },
{ display: 'Autocomplete', featureName: 'Autocomplete' },
{ display: 'Fast Apply', featureName: 'Apply' },
];
const AddProvidersPage = ({ pageIndex, setPageIndex }: { pageIndex: number, setPageIndex: (index: number) => void }) => {
const [currentTab, setCurrentTab] = useState<TabName>('Free');
const settingsState = useSettingsState();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Clear error message after 5 seconds
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
if (errorMessage) {
timeoutId = setTimeout(() => {
setErrorMessage(null);
}, 5000);
}
// Cleanup function to clear the timeout if component unmounts or error changes
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [errorMessage]);
return (
<div className="flex flex-col md:flex-row w-full h-[80vh] gap-6 max-w-[900px] mx-auto relative">
{/* Left Column - Fixed */}
<div className="md:w-1/4 w-full flex flex-col gap-6 p-6 border-r border-void-border-2 h-full overflow-y-auto">
{/* Tab Selector */}
<div className="flex md:flex-col gap-2">
{[...tabNames, 'Cloud/Other'].map(tab => (
<button
key={tab}
className={`py-2 px-4 rounded-md text-left ${currentTab === tab
? 'bg-[#0e70c0]/80 text-white font-medium shadow-sm'
: 'bg-void-bg-2 hover:bg-void-bg-2/80 text-void-fg-1'
} transition-all duration-200`}
onClick={() => {
setCurrentTab(tab as TabName);
setErrorMessage(null); // Reset error message when changing tabs
}}
>
{tab}
</button>
))}
</div>
{/* Feature Checklist */}
<div className="flex flex-col gap-1 mt-4 text-sm opacity-80">
{featureNameMap.map(({ display, featureName }) => {
const hasModel = settingsState.modelSelectionOfFeature[featureName] !== null;
return (
<div key={featureName} className="flex items-center gap-2">
{hasModel ? (
<Check className="w-4 h-4 text-emerald-500" />
) : (
<div className="w-3 h-3 rounded-full flex items-center justify-center">
<div className="w-1 h-1 rounded-full bg-white/70"></div>
</div>
)}
<span>{display}</span>
</div>
);
})}
</div>
</div>
{/* Right Column */}
<div className="flex-1 flex flex-col items-center justify-start p-6 h-full overflow-y-auto">
<div className="text-4xl font-light mb-2 text-center w-full">{currentTab}</div>
<div className="text-sm text-void-fg-3 mb-2 text-center w-full max-w-lg">{descriptionOfTab[currentTab]}</div>
{providerNamesOfTab[currentTab].map((providerName) => (
<div key={providerName} className="w-full max-w-xl mb-10">
<div className="text-xl mb-2">Add {displayInfoOfProviderName(providerName).title}</div>
<SettingsForProvider providerName={providerName} showProviderTitle={false} showProviderSuggestions={true} />
{providerName === 'ollama' && <OllamaSetupInstructions />}
</div>
))}
{(currentTab === 'Local' || currentTab === 'Cloud/Other') && (
<div className="w-full max-w-xl mt-8 bg-void-bg-2/50 rounded-lg p-6 border border-void-border-2/30">
<div className="flex items-center gap-2 mb-4">
<div className="text-xl font-medium text-[#0e70c0]">Models</div>
<div className="h-px flex-grow bg-void-border-2/30"></div>
</div>
{currentTab === 'Local' && (
<div className="text-sm text-void-fg-3 mb-4 bg-void-bg-3/30 p-3 rounded border-l-2 border-[#0e70c0]/70">
Local models should be detected automatically. You can add custom models below.
</div>
)}
{currentTab === 'Local' && <ModelDump filteredProviders={localProviderNames} />}
{currentTab === 'Cloud/Other' && <ModelDump filteredProviders={cloudProviders} />}
</div>
)}
{currentTab === 'Free' && <div className='opacity-80'>
<div className="pl-2 flex flex-col gap-y-4 text-sm text-void-fg-3 mb-4 w-full">
<ChatMarkdownRender string={`
Gemini 2.5 Pro offers 25 free messages a day, and Gemini 2.5 Flash offers 500.
We recommend using models down the line as you run out of free credits. More information [here](https://ai.google.dev/gemini-api/docs/rate-limits#current-rate-limits).
`} chatMessageLocation={undefined} /></div>
<div className="pl-2 flex flex-col gap-y-4 text-sm text-void-fg-3 mb-4 w-full">
<ChatMarkdownRender string={`
OpenRouter offers 50 free messages a day, and 1000 if you deposit $10.
Only applies to models labeled \`:free\`. More information [here](https://openrouter.ai/docs/api-reference/limits).
`} chatMessageLocation={undefined} /></div>
</div>
}
{/* Navigation buttons in right column */}
<div className="flex flex-col items-end w-full mt-auto pt-8">
{errorMessage && (
<div className="text-amber-400 mb-2 text-sm opacity-80 transition-opacity duration-300">{errorMessage}</div>
)}
<div className="flex items-center gap-2">
<PreviousButton onClick={() => setPageIndex(pageIndex - 1)} />
<NextButton
onClick={() => {
const isDisabled = isFeatureNameDisabled('Chat', settingsState)
if (!isDisabled) {
setPageIndex(pageIndex + 1);
setErrorMessage(null);
} else {
// Show error message
setErrorMessage("Please set up at least one Chat model before moving on.");
}
}}
/>
</div>
</div>
</div>
</div>
);
};
// =============================================
// OnboardingPage
// title:
// div
@ -179,7 +353,7 @@ const OnboardingPageShell = ({ top, bottom, content, hasMaxWidth = true, classNa
className?: string,
}) => {
return (
<div className={`min-h-full text-lg flex flex-col gap-4 w-full mx-auto ${hasMaxWidth ? 'max-w-[600px]' : ''} ${className}`}>
<div className={`h-[80vh] text-lg flex flex-col gap-4 w-full mx-auto ${hasMaxWidth ? 'max-w-[600px]' : ''} ${className}`}>
{top && <FadeIn className='w-full mb-auto pt-16'>{top}</FadeIn>}
{content && <FadeIn className='w-full my-auto'>{content}</FadeIn>}
{bottom && <div className='w-full pb-8'>{bottom}</div>}
@ -188,8 +362,6 @@ const OnboardingPageShell = ({ top, bottom, content, hasMaxWidth = true, classNa
}
const OllamaDownloadOrRemoveModelButton = ({ modelName, isModelInstalled, sizeGb }: { modelName: string, isModelInstalled: boolean, sizeGb: number | false | 'not-known' }) => {
// for now just link to the ollama download page
return <a
href={`https://ollama.com/library/${modelName}`}
@ -200,76 +372,6 @@ const OllamaDownloadOrRemoveModelButton = ({ modelName, isModelInstalled, sizeGb
<ExternalLink className="w-3.5 h-3.5" />
</a>
// if (isModelInstalled) {
// return <div className="flex items-center">
// <span className="flex items-center">Uninstall</span>
// <IconShell1
// className="ml-1"
// Icon={Trash}
// onClick={() => {
// setIsModelInstalling(false);
// }}
// />
// </div>
// }
// else if (isModelInstalling) {
// return <div className="flex items-center">
// <span className="flex items-center">{`Download? ${typeof sizeGb === 'number' ? `(${sizeGb} Gb)` : ''}`}</span>
// <IconShell1
// className="ml-1"
// Icon={Square}
// onClick={() => {
// // abort()
// // TODO!!!!!!!!!!! don't do this
// setIsModelInstalling(false);
// }}
// />
// </div>
// }
// else if (!isModelInstalled) {
// return <div className="flex items-center">
// <span className="flex items-center">Download ({sizeGb} Gb)</span>
// <IconShell1
// className="ml-1"
// Icon={Download}
// onClick={() => {
// // this is a check for whether the model was installed:
// if (isModelInstalling) return
// // TODO!!!!!! don't do this
// // install(modelname), callback = setIsModelInstalling(false);
// setIsModelInstalling(true);
// }}
// />
// </div>
// }
// return <></>
}
@ -306,114 +408,7 @@ const abbreviateNumber = (num: number): string => {
}
}
const TableOfModelsForProvider = ({ providerName }: { providerName: ProviderName }) => {
const accessor = useAccessor()
const voidSettingsService = accessor.get('IVoidSettingsService')
const voidSettingsState = useSettingsState()
const isDetectableLocally = (refreshableProviderNames as ProviderName[]).includes(providerName)
// const providerCapabilities = getProviderCapabilities(providerName)
// info used to show the table
const infoOfModelName: Record<string, { showAsDefault: boolean, isDownloaded: boolean } | undefined> = {}
voidSettingsState.settingsOfProvider[providerName].models.forEach(m => {
infoOfModelName[m.modelName] = {
showAsDefault: m.type !== 'custom',
isDownloaded: true
}
})
// special case columns for ollama; show recommended models as default
if (providerName === 'ollama') {
for (const modelName of ollamaRecommendedModels) {
if (modelName in infoOfModelName) continue
infoOfModelName[modelName] = {
isDownloaded: infoOfModelName[modelName]?.isDownloaded ?? false,
showAsDefault: true,
}
}
}
return <table className="table-fixed border-collapse mb-6 bg-void-bg-2 text-sm mx-auto select-text">
<thead>
<tr className="border-b border-void-border-1 text-nowrap text-ellipsis">
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[200px]">Models Offered</th>
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Cost/M</th>
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Context</th>
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Chat</th>
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Agent</th>
<th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Autotab</th>
{/* <th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Reasoning</th> */}
{isDetectableLocally && <th className="text-left py-2 px-3 font-normal text-void-fg-3 min-w-[10%]">Detected</th>}
{providerName === 'ollama' && <th className="text-left py-2 px-3 font-normal text-void-fg-3">Download</th>}
</tr>
</thead>
<tbody>
{Object.keys(infoOfModelName).map(modelName => {
const { showAsDefault, isDownloaded } = infoOfModelName[modelName] ?? {}
const capabilities = getModelCapabilities(providerName, modelName, undefined)
const {
downloadable,
cost,
supportsFIM,
reasoningCapabilities,
contextWindow,
isUnrecognizedModel,
reservedOutputTokenSpace,
supportsSystemMessage,
} = capabilities
// TODO update this when tools work
const removeModelButton = <button
className="absolute -left-1 top-1/2 transform -translate-y-1/2 -translate-x-full text-void-fg-3 hover:text-void-fg-1 text-xs"
onClick={() => voidSettingsService.deleteModel(providerName, modelName)}
>
<X className="w-3.5 h-3.5" />
</button>
return (
<tr key={`${modelName}${providerName}`} className="border-b border-void-border-1 hover:bg-void-bg-3/50">
<td className="py-2 px-3 relative">
{!showAsDefault && removeModelButton}
{modelName}
</td>
<td className="py-2 px-3">${cost.output ?? ''}</td>
<td className="py-2 px-3">{contextWindow ? abbreviateNumber(contextWindow) : ''}</td>
<td className="py-2 px-3"><YesNoText val={true} /></td>
<td className="py-2 px-3"><YesNoText val={!!true} /></td>
<td className="py-2 px-3"><YesNoText val={!!supportsFIM} /></td>
{/* <td className="py-2 px-3"><YesNoText val={!!reasoningCapabilities} /></td> */}
{isDetectableLocally && <td className="py-2 px-3 flex items-center justify-center">{!!isDownloaded ? <Check className="w-4 h-4" /> : <></>}</td>}
{providerName === 'ollama' && <th className="py-2 px-3">
<OllamaDownloadOrRemoveModelButton modelName={modelName} isModelInstalled={!!infoOfModelName[modelName]?.isDownloaded} sizeGb={downloadable && downloadable.sizeGb} />
</th>}
</tr>
)
})}
<tr className="hover:bg-void-bg-3/50">
<td className="py-2 px-3 text-void-accent">
<ErrorBoundary>
<AddModelInputBox
key={providerName}
providerName={providerName}
compact={true}
/>
</ErrorBoundary>
</td>
<td colSpan={4}></td>
</tr>
</tbody>
</table>
}
@ -431,22 +426,16 @@ const PrimaryActionButton = ({ children, className, ringSize, ...props }: { chil
${ringSize === 'xl' ? `
gap-2 px-16 py-8
hover:ring-8 active:ring-8
transition-all duration-300 ease-in-out
`
: ringSize === 'screen' ? `
gap-2 px-16 py-8
ring-[3000px]
transition-all duration-1000 ease-in-out
`: ringSize === undefined ? `
gap-1 px-4 py-2
hover:ring-2 active:ring-2
transition-all duration-300 ease-in-out
`: ''}
hover:ring-black/90 dark:hover:ring-white/90
active:ring-black/90 dark:active:ring-white/90
rounded-lg
group
${className}
@ -534,7 +523,6 @@ const VoidOnboardingContent = () => {
/>
<NextButton
onClick={() => { setPageIndex(pageIndex + 1) }}
disabled={pageIndex === 2 && !didFillInSelectedProviderSettings}
/>
</div>
</div>
@ -612,7 +600,7 @@ const VoidOnboardingContent = () => {
delayMs={1000}
>
<PrimaryActionButton
onClick={() => { setPageIndex(pageIndex + 1) }}
onClick={() => { setPageIndex(1) }}
>
Get Started
</PrimaryActionButton>
@ -621,255 +609,13 @@ const VoidOnboardingContent = () => {
</div>
}
/>,
1: <OnboardingPageShell
hasMaxWidth={false}
top={<></>}
content={<div className='flex flex-col items-center -translate-y-[20vh]'>
{/* <div className="text-5xl text-center mb-8">AI Preferences</div> */}
<div className="text-4xl text-void-fg-2 mb-8 text-center">Model Preferences</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full max-w-[800px] mx-auto mt-8">
<button
onClick={() => { setWantToUseOption('cheap'); setPageIndex(pageIndex + 1); }}
className="flex flex-col p-6 rounded bg-void-bg-2 border border-void-border-3 hover:brightness-110 transition-colors focus:outline-none focus:border-void-accent-border relative overflow-hidden min-h-[160px]"
>
<div className="flex items-center mb-3">
<DollarSign size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Affordable</div>
</div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['cheap']}</div>
</button>
<button
onClick={() => { setWantToUseOption('private'); setPageIndex(pageIndex + 1); }}
className="flex flex-col p-6 rounded bg-void-bg-2 border border-void-border-3 hover:brightness-110 transition-colors focus:outline-none focus:border-void-accent-border relative overflow-hidden min-h-[160px]"
>
<div className="flex items-center mb-3">
<Lock size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Private</div>
</div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['private']}</div>
</button>
<button
onClick={() => { setWantToUseOption('smart'); setPageIndex(pageIndex + 1); }}
className="flex flex-col p-6 rounded bg-void-bg-2 border border-void-border-3 hover:brightness-110 transition-colors focus:outline-none focus:border-void-accent-border relative overflow-hidden min-h-[160px]"
>
<div className="flex items-center mb-3">
<Brain size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Intelligent</div>
</div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['smart']}</div>
</button>
</div>
</div>}
bottom={
<div className='mx-auto w-full max-w-[800px]'>
<PreviousButton onClick={() => { setPageIndex(pageIndex - 1) }} />
</div>
1: <OnboardingPageShell hasMaxWidth={false}
content={
<AddProvidersPage pageIndex={pageIndex} setPageIndex={setPageIndex} />
}
/>,
2: <OnboardingPageShell
top={
<>
{/* Title */}
<div className="text-5xl font-light text-center mt-[10vh] mb-6">Choose a Provider</div>
{/* Preference Selector */}
<div
className="mb-6 w-fit mx-auto flex items-center overflow-hidden bg-zinc-700/5 dark:bg-zinc-300/5 rounded-md"
>
{[
{ id: 'smart', label: 'Intelligent' },
{ id: 'private', label: 'Private' },
{ id: 'cheap', label: 'Affordable' },
{ id: 'all', label: 'All' }
].map(option => (
<ErrorBoundary
key={option.id}
>
<button
onClick={() => setWantToUseOption(option.id as WantToUseOption)}
className={`py-1 px-2 text-xs cursor-pointer whitespace-nowrap rounded-sm transition-colors ${wantToUseOption === option.id
? 'dark:text-white text-black font-medium'
: 'text-void-fg-3 hover:text-void-fg-2'
}`}
data-tooltip-id='void-tooltip'
data-tooltip-content={`${option.label} providers`}
data-tooltip-place='bottom'
>
{option.label}
</button>
</ErrorBoundary>
))}
</div>
{/* Provider Buttons - Modified to use separate components for each tab */}
<div className="mb-2 w-full">
{/* Intelligent tab */}
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'smart' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['smart'].map((providerName) => {
const isSelected = selectedIntelligentProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedIntelligentProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* Private tab */}
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'private' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['private'].map((providerName) => {
const isSelected = selectedPrivateProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedPrivateProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* Affordable tab */}
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'cheap' ? 'flex' : 'hidden'}`}>
{providerNamesOfWantToUseOption['cheap'].map((providerName) => {
const isSelected = selectedAffordableProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAffordableProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
{/* All tab */}
<ErrorBoundary>
<div className={`flex flex-wrap items-center w-full ${wantToUseOption === 'all' ? 'flex' : 'hidden'}`}>
{providerNames.map((providerName) => {
const isSelected = selectedAllProvider === providerName;
return (
<button
key={providerName}
onClick={() => setSelectedAllProvider(providerName)}
className={`py-[2px] px-2 mx-0.5 my-0.5 text-xs font-medium cursor-pointer relative rounded-full transition-all duration-300
${isSelected ? 'bg-zinc-100 text-zinc-900 shadow-sm border-white/80' : 'bg-zinc-100/40 hover:bg-zinc-100/50 text-zinc-900 border-white/20'}`}
>
{displayInfoOfProviderName(providerName).title}
</button>
);
})}
</div>
</ErrorBoundary>
</div>
{/* Description */}
<ErrorBoundary>
<div className="text-left self-start text-sm text-void-fg-3 px-2 py-1">
<ChatMarkdownRender string={detailedDescOfWantToUseOption[wantToUseOption]} chatMessageLocation={undefined} />
</div>
</ErrorBoundary>
{/* ModelsTable and ProviderFields */}
{selectedProviderName && <div className='mt-4 w-fit mx-auto'>
{/* Models Table */}
<ErrorBoundary>
<TableOfModelsForProvider providerName={selectedProviderName} />
</ErrorBoundary>
{/* Add provider section - simplified styling */}
<div className='mb-5 mt-8 mx-auto'>
<ErrorBoundary>
<div className=''>
Add {displayInfoOfProviderName(selectedProviderName).title}
<div className='my-4'>
{selectedProviderName === 'ollama' ? <OllamaSetupInstructions /> : ''}
</div>
</div>
</ErrorBoundary>
<ErrorBoundary>
{selectedProviderName &&
<SettingsForProvider providerName={selectedProviderName} showProviderTitle={false} showProviderSuggestions={false} />
}
</ErrorBoundary>
{/* Button and status indicators */}
<ErrorBoundary>
{!didFillInProviderSettings ? <p className="text-xs text-void-fg-3 mt-2">Please fill in all fields to continue</p>
: !isAtLeastOneModel ? <p className="text-xs text-void-fg-3 mt-2">Please add a model to continue</p>
: !isApiKeyLongEnoughIfApiKeyExists ? <p className="text-xs text-void-fg-3 mt-2">Please enter a valid API key</p>
: <AnimatedCheckmarkButton className='text-xs text-void-fg-3 mt-2' text='Added' />}
</ErrorBoundary>
</div>
</div>}
</>
}
bottom={
<ErrorBoundary>
<FadeIn delayMs={50} durationMs={10}>
{prevAndNextButtons}
</FadeIn>
</ErrorBoundary>
}
/>,
// 2.5: <div className="max-w-[600px] w-full h-full text-left mx-auto flex flex-col items-center justify-between">
// <FadeIn>
// <div className="text-5xl font-light mb-6 mt-12 text-center">Autocomplete</div>
// <div className="text-center flex flex-col gap-4 w-full max-w-md mx-auto">
// <h4 className="text-void-fg-3 mb-2">Void offers free autocomplete with locally hosted models</h4>
// <h4 className="text-void-fg-3 mb-2">[have buttons for Ollama install Qwen2.5coder3b and memory requirements] </h4>
// </div>
// </FadeIn>
// {prevAndNextButtons}
// </div>,
3: <OnboardingPageShell
content={
<div>
@ -884,32 +630,11 @@ const VoidOnboardingContent = () => {
</div>
}
bottom={lastPagePrevAndNextButtons}
// bottom={prevAndNextButtons}
/>,
// 4: <OnboardingPageShell
// content={
// <>
// <div
// className='flex justify-center'
// >
// <PrimaryActionButton
// onClick={() => { voidSettingsService.setGlobalSetting('isOnboardingComplete', true); }}
// ringSize={voidSettingsState.globalSettings.isOnboardingComplete ? 'screen' : undefined}
// className='text-4xl'
// >Enter the Void</PrimaryActionButton>
// </div>
// </>
// }
// bottom={
// <PreviousButton
// onClick={() => { setPageIndex(pageIndex - 1) }}
// />
// }
// />,
}
return <div key={pageIndex} className="w-full h-full text-left mx-auto overflow-y-scroll flex flex-col items-center justify-around">
return <div key={pageIndex} className="w-full h-[80vh] text-left mx-auto flex flex-col items-center justify-center">
<ErrorBoundary>
{contentOfIdx[pageIndex]}
</ErrorBoundary>

View file

@ -361,127 +361,9 @@ const SimpleModelSettingsDialog = ({
};
// shows a providerName dropdown if no `providerName` is given
export const AddModelInputBox = ({ providerName: permanentProviderName, className, compact }: { providerName?: ProviderName, className?: string, compact?: boolean }) => {
const accessor = useAccessor()
const settingsStateService = accessor.get('IVoidSettingsService')
const settingsState = useSettingsState()
const [isOpen, setIsOpen] = useState(false)
const [showCheckmark, setShowCheckmark] = useState(false)
// const providerNameRef = useRef<ProviderName | null>(null)
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName | null>(null)
const providerName = permanentProviderName ?? userChosenProviderName;
const [modelName, setModelName] = useState<string>('')
const [errorString, setErrorString] = useState('')
const numModels = providerName === null ? 0 : settingsState.settingsOfProvider[providerName].models.length
if (showCheckmark) {
return <AnimatedCheckmarkButton text='Added' className={`bg-[#0e70c0] text-white px-3 py-1 rounded-sm ${className}`} />
}
if (!isOpen) {
return <div
className={`text-void-fg-4 flex flex-nowrap text-nowrap items-center hover:brightness-110 cursor-pointer ${className}`}
onClick={() => setIsOpen(true)}
>
<div>
{numModels > 0 ? `Add a different model?` : `Add a model`}
</div>
</div>
}
return <>
<form className={`flex items-center gap-2 ${className}`}>
{/* X button
<button onClick={() => { setIsOpen(false) }} className='text-void-fg-4'><X className='size-4' /></button> */}
{/* provider input */}
<ErrorBoundary>
{!permanentProviderName &&
<VoidCustomDropdownBox
options={providerNames}
selectedOption={providerName}
onChangeOption={(pn) => setUserChosenProviderName(pn)}
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]`}
className={`max-w-32 mx-2 w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1 py-1 px-2 rounded`}
arrowTouchesText={false}
/>
}
</ErrorBoundary>
{/* model input */}
<ErrorBoundary>
<VoidSimpleInputBox
value={modelName}
onChangeValue={setModelName}
placeholder='Model Name'
compact={compact}
className={'max-w-32'}
/>
</ErrorBoundary>
{/* add button */}
<ErrorBoundary>
<AddButton
type='submit'
disabled={!modelName}
onClick={(e) => {
if (providerName === null) {
setErrorString('Please select a provider.')
return
}
if (!modelName) {
setErrorString('Please enter a model name.')
return
}
// if model already exists here
if (settingsState.settingsOfProvider[providerName].models.find(m => m.modelName === modelName)) {
// setErrorString(`This model already exists under ${providerName}.`)
setErrorString(`This model already exists.`)
return
}
settingsStateService.addModel(providerName, modelName)
setShowCheckmark(true)
setTimeout(() => {
setShowCheckmark(false)
setIsOpen(false)
}, 1500)
setErrorString('')
setModelName('')
}}
/>
</ErrorBoundary>
</form>
{!errorString ? null : <div className='text-red-500 truncate whitespace-nowrap mt-1'>
{errorString}
</div>}
</>
}
export const ModelDump = () => {
export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderName[] }) => {
const accessor = useAccessor()
const settingsStateService = accessor.get('IVoidSettingsService')
const settingsState = useSettingsState()
@ -493,9 +375,20 @@ export const ModelDump = () => {
type: 'autodetected' | 'custom' | 'default'
} | null>(null);
// States for add model functionality
const [isAddModelOpen, setIsAddModelOpen] = useState(false);
const [showCheckmark, setShowCheckmark] = useState(false);
const [userChosenProviderName, setUserChosenProviderName] = useState<ProviderName | null>(null);
const [modelName, setModelName] = useState<string>('');
const [errorString, setErrorString] = useState('');
// a dump of all the enabled providers' models
const modelDump: (VoidStatefulModelInfo & { providerName: ProviderName, providerEnabled: boolean })[] = []
for (let providerName of providerNames) {
// Use either filtered providers or all providers
const providersToShow = filteredProviders || providerNames;
for (let providerName of providersToShow) {
const providerSettings = settingsState.settingsOfProvider[providerName]
// if (!providerSettings.enabled) continue
modelDump.push(...providerSettings.models.map(model => ({ ...model, providerName, providerEnabled: !!providerSettings._didFillInProviderSettings })))
@ -506,6 +399,34 @@ export const ModelDump = () => {
return Number(b.providerEnabled) - Number(a.providerEnabled)
})
// Add model handler
const handleAddModel = () => {
if (!userChosenProviderName) {
setErrorString('Please select a provider.');
return;
}
if (!modelName) {
setErrorString('Please enter a model name.');
return;
}
// Check if model already exists
if (settingsState.settingsOfProvider[userChosenProviderName].models.find(m => m.modelName === modelName)) {
setErrorString(`This model already exists.`);
return;
}
settingsStateService.addModel(userChosenProviderName, modelName);
setShowCheckmark(true);
setTimeout(() => {
setShowCheckmark(false);
setIsAddModelOpen(false);
setUserChosenProviderName(null);
setModelName('');
}, 1500);
setErrorString('');
};
return <div className=''>
{modelDump.map((m, i) => {
const { isHidden, type, modelName, providerName, providerEnabled } = m
@ -584,6 +505,82 @@ export const ModelDump = () => {
</div>
})}
{/* Add Model Section */}
{showCheckmark ? (
<div className="mt-4">
<AnimatedCheckmarkButton text='Added' className="bg-[#0e70c0] text-white px-3 py-1 rounded-sm" />
</div>
) : isAddModelOpen ? (
<div className="mt-4">
<form className="flex items-center gap-2">
{/* Provider dropdown */}
<ErrorBoundary>
<VoidCustomDropdownBox
options={providersToShow}
selectedOption={userChosenProviderName}
onChangeOption={(pn) => setUserChosenProviderName(pn)}
getOptionDisplayName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionDropdownName={(pn) => pn ? displayInfoOfProviderName(pn).title : 'Provider Name'}
getOptionsEqual={(a, b) => a === b}
className="max-w-32 mx-2 w-full resize-none bg-void-bg-1 text-void-fg-1 placeholder:text-void-fg-3 border border-void-border-2 focus:border-void-border-1 py-1 px-2 rounded"
arrowTouchesText={false}
/>
</ErrorBoundary>
{/* Model name input */}
<ErrorBoundary>
<VoidSimpleInputBox
value={modelName}
compact={true}
onChangeValue={setModelName}
placeholder='Model Name'
className='max-w-32'
/>
</ErrorBoundary>
{/* Add button */}
<ErrorBoundary>
<AddButton
type='button'
disabled={!modelName || !userChosenProviderName}
onClick={handleAddModel}
/>
</ErrorBoundary>
{/* X button to cancel */}
<button
type="button"
onClick={() => {
setIsAddModelOpen(false);
setErrorString('');
setModelName('');
setUserChosenProviderName(null);
}}
className='text-void-fg-4'
>
<X className='size-4' />
</button>
</form>
{errorString && (
<div className='text-red-500 truncate whitespace-nowrap mt-1'>
{errorString}
</div>
)}
</div>
) : (
<div
className="text-void-fg-4 flex flex-nowrap text-nowrap items-center hover:brightness-110 cursor-pointer mt-4"
onClick={() => setIsAddModelOpen(true)}
>
<div className="flex items-center gap-1">
<Plus size={16} />
<span>Add a model</span>
</div>
</div>
)}
{/* Model Settings Dialog */}
<SimpleModelSettingsDialog
isOpen={openSettingsModel !== null}
@ -610,15 +607,17 @@ const ProviderSetting = ({ providerName, settingName, subTextMd }: { providerNam
console.log('Error: Provider setting had a non-string value.')
return
}
// Create a stable callback reference using useCallback with proper dependencies
const handleChangeValue = useCallback((newVal: string) => {
voidSettingsService.setSettingOfProvider(providerName, settingName, newVal)
}, [voidSettingsService, providerName, settingName]);
return <ErrorBoundary>
<div className='my-1'>
<VoidSimpleInputBox
value={settingValue}
onChangeValue={useCallback((newVal) => {
voidSettingsService.setSettingOfProvider(providerName, settingName, newVal)
}, [voidSettingsService, providerName, settingName])}
// placeholder={`${providerTitle} ${settingTitle} (${placeholder})`}
onChangeValue={handleChangeValue}
placeholder={`${settingTitle} (${placeholder})`}
passwordBlur={isPasswordField}
compact={true}
@ -720,8 +719,8 @@ export const SettingsForProvider = ({ providerName, showProviderTitle, showProvi
{showProviderSuggestions && needsModel ?
providerName === 'ollama' ?
<WarningBox className="mt-1" text={`Please install an Ollama model. We'll auto-detect it.`} />
: <WarningBox className="mt-1" text={`Please add a model for ${providerTitle} (Models section).`} />
<WarningBox className="pl-2 mb-4" text={`Please install an Ollama model. We'll auto-detect it.`} />
: <WarningBox className="pl-2 mb-4" text={`Please add a model for ${providerTitle} (Models section).`} />
: null}
</div>
</div >
@ -804,7 +803,7 @@ const FastApplyMethodDropdown = () => {
}
export const OllamaSetupInstructions = () => {
export const OllamaSetupInstructions = ({ sayWeAutoDetect }: { sayWeAutoDetect?: boolean }) => {
return <div className='prose-p:my-0 prose-ol:list-decimal prose-p:py-0 prose-ol:my-0 prose-ol:py-0 prose-span:my-0 prose-span:py-0 text-void-fg-3 text-sm list-decimal select-text'>
<div className=''><ChatMarkdownRender string={`Ollama Setup Instructions`} chatMessageLocation={undefined} /></div>
<div className=' pl-6'><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} chatMessageLocation={undefined} /></div>
@ -815,7 +814,7 @@ export const OllamaSetupInstructions = () => {
>
<ChatMarkdownRender string={`3. Run \`ollama pull your_model\` to install a model.`} chatMessageLocation={undefined} />
</div>
<div className=' pl-6'><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} chatMessageLocation={undefined} /></div>
{sayWeAutoDetect && <div className=' pl-6'><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} chatMessageLocation={undefined} /></div>}
</div>
}
@ -1176,17 +1175,20 @@ export const Settings = () => {
<h1 className='text-2xl w-full'>{`Void's Settings`}</h1>
{/* separator */}
<div className='w-full h-[1px] my-4' />
<div className='w-full h-[1px] my-2' />
{/* Models section (formerly FeaturesTab) */}
<ErrorBoundary>
<RedoOnboardingButton />
</ErrorBoundary>
<div className='w-full h-[1px] my-4' />
{/* Models section (formerly FeaturesTab) */}
<ErrorBoundary>
<h2 className={`text-3xl mb-2`}>Models</h2>
<ModelDump />
<AddModelInputBox className='mt-4' compact />
<RedoOnboardingButton className='mt-2 mb-4' />
<div className='w-full h-[1px] my-4' />
<AutoDetectLocalModelsToggle />
<RefreshableModels />
</ErrorBoundary>
@ -1196,7 +1198,7 @@ export const Settings = () => {
<h3 className={`text-void-fg-3 mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3>
<div className='opacity-80 mb-4'>
<OllamaSetupInstructions />
<OllamaSetupInstructions sayWeAutoDetect={true} />
</div>
<ErrorBoundary>

View file

@ -112,11 +112,14 @@ export const VoidTooltip = () => {
</div>
<div style={{ marginBottom: 4 }}>
<span style={{ opacity: 0.8 }}>For chat:{` `}</span>
<span style={{ opacity: 0.8, fontWeight: 'bold' }}>llama3.1</span>
<span style={{ opacity: 0.8, fontWeight: 'bold' }}>gemma3</span>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<span style={{ opacity: 0.8 }}>For autocomplete:{` `}</span>
<span style={{ opacity: 0.8, fontWeight: 'bold' }}>qwen2.5-coder:1.5b</span>
<span style={{ opacity: 0.8, fontWeight: 'bold' }}>qwen2.5-coder</span>
</div>
<div style={{ marginBottom: 0 }}>
<span style={{ opacity: 0.8 }}>Use the largest version of these you can!</span>
</div>
</div>
</Tooltip>

View file

@ -14,23 +14,18 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { VOID_VIEW_ID } from './sidebarPane.js';
import { VOID_VIEW_CONTAINER_ID, 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 { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize2 } from '../../../../nls.js';
import { StagingSelectionItem } from '../common/chatThreadServiceTypes.js';
import { IChatThreadService } from './chatThreadService.js';
import { getActiveWindow } from '../../../../base/browser/dom.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
// ---------- Register commands and keybindings ----------
export const roundRangeToLines = (range: IRange | null | undefined, options: { emptySelectionBehavior: 'null' | 'line' }) => {
if (!range)
return null
@ -72,68 +67,21 @@ registerAction2(class extends Action2 {
super({ id: VOID_OPEN_SIDEBAR_ACTION_ID, title: localize2('voidOpenSidebar', 'Void: Open Sidebar'), f1: true });
}
async run(accessor: ServicesAccessor): Promise<void> {
const stateService = accessor.get(ISidebarStateService)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
stateService.fireFocusChat()
const viewsService = accessor.get(IViewsService)
const chatThreadsService = accessor.get(IChatThreadService)
viewsService.openViewContainer(VOID_VIEW_CONTAINER_ID)
await chatThreadsService.focusCurrentChat()
}
})
// Action: when press ctrl+L, show the sidebar chat and add to the selection
const VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID = 'void.sidebar.select'
registerAction2(class extends Action2 {
constructor() {
super({ id: VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID, title: localize2('voidAddToSidebar', 'Void: Add Selection to Sidebar'), f1: true });
}
async run(accessor: ServicesAccessor): Promise<void> {
const model = accessor.get(ICodeEditorService).getActiveCodeEditor()?.getModel()
if (!model)
return
const metricsService = accessor.get(IMetricsService)
const editorService = accessor.get(ICodeEditorService)
metricsService.capture('Ctrl+L', {})
const editor = editorService.getActiveCodeEditor()
// accessor.get(IEditorService).activeTextEditorControl?.getSelection()
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
// select whole lines
if (selectionRange) {
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
}
const newSelection: StagingSelectionItem = !selectionRange || (selectionRange.startLineNumber > selectionRange.endLineNumber) ? {
type: 'File',
uri: model.uri,
language: model.getLanguageId(),
state: { wasAddedAsCurrentFile: false }
} : {
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false }
}
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.addNewStagingSelection(newSelection)
}
});
// cmd L
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_CTRL_L_ACTION_ID,
f1: true,
title: localize2('voidCtrlL', 'Void: Add Selection to Chat'),
title: localize2('voidCmdL', 'Void: Add Selection to Chat'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension
@ -141,72 +89,115 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
// Get the views service to check if the sidebar is open
// const viewsService = accessor.get(IViewsService)
const commandService = accessor.get(ICommandService)
await commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
// await commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID)
}
})
const openNewThreadAndFireFocus = (accessor: ServicesAccessor) => {
const stateService = accessor.get(ISidebarStateService)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.openNewThread()
// focus
stateService.fireFocusChat()
const window = getActiveWindow()
window.requestAnimationFrame(() => stateService.fireFocusChat())
}
// New chat menu button
registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.newChatAction',
title: 'New Chat',
icon: { id: 'add' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }],
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const viewsService = accessor.get(IViewsService)
const metricsService = accessor.get(IMetricsService)
metricsService.capture('Chat Navigation', { type: 'New Chat' })
const editorService = accessor.get(ICodeEditorService)
const chatThreadService = accessor.get(IChatThreadService)
metricsService.capture('Ctrl+L', {})
const wasAlreadyOpen = viewsService.isViewContainerVisible(VOID_VIEW_CONTAINER_ID)
if (!wasAlreadyOpen) {
await commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
return
}
// if was already open
const model = accessor.get(ICodeEditorService).getActiveCodeEditor()?.getModel()
if (!model) return
const editor = editorService.getActiveCodeEditor()
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
// if has no selection, close + return
if (!selectionRange) {
viewsService.closeViewContainer(VOID_VIEW_CONTAINER_ID);
return;
}
// if has selection, add it
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
chatThreadService.addNewStagingSelection({
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false },
})
await chatThreadService.focusCurrentChat()
openNewThreadAndFireFocus(accessor)
}
})
// New chat keybind
// New chat keybind + menu button
const VOID_CMD_SHIFT_L_ACTION_ID = 'void.cmdShiftL'
registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.newChatKeybindAction',
title: 'New Chat Keybind',
id: VOID_CMD_SHIFT_L_ACTION_ID,
title: 'New Chat',
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension,
},
icon: { id: 'add' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }],
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const metricsService = accessor.get(IMetricsService)
const commandService = accessor.get(ICommandService)
metricsService.capture('Chat Navigation', { type: 'New Chat Keybind' })
const chatThreadsService = accessor.get(IChatThreadService)
const editorService = accessor.get(ICodeEditorService)
metricsService.capture('Chat Navigation', { type: 'Start New Chat' })
openNewThreadAndFireFocus(accessor)
// get current selections and value to transfer
const oldThreadId = chatThreadsService.state.currentThreadId
const oldThread = chatThreadsService.state.allThreads[oldThreadId]
// add user's selection to chat
await commandService.executeCommand(VOID_CTRL_L_ACTION_ID)
const oldUI = await oldThread?.state.mountedInfo?.whenMounted
const oldSelns = oldThread?.state.stagingSelections
const oldVal = oldUI?.textAreaRef.current?.value
// open and focus new thread
chatThreadsService.openNewThread()
await chatThreadsService.focusCurrentChat()
// set new thread values
const newThreadId = chatThreadsService.state.currentThreadId
const newThread = chatThreadsService.state.allThreads[newThreadId]
const newUI = await newThread?.state.mountedInfo?.whenMounted
chatThreadsService.setCurrentThreadState({ stagingSelections: oldSelns, })
if (newUI?.textAreaRef?.current && oldVal) newUI.textAreaRef.current.value = oldVal
// if has selection, add it
const editor = editorService.getActiveCodeEditor()
const model = editor?.getModel()
if (!model) return
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
if (!selectionRange) return
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
chatThreadsService.addNewStagingSelection({
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false },
})
}
})
@ -229,18 +220,12 @@ registerAction2(class extends Action2 {
return;
}
const stateService = accessor.get(ISidebarStateService)
const metricsService = accessor.get(IMetricsService)
const commandService = accessor.get(ICommandService)
metricsService.capture('Chat Navigation', { type: 'History' })
openNewThreadAndFireFocus(accessor)
// doesnt do anything right now
stateService.setState({ isHistoryOpen: !stateService.state.isHistoryOpen, currentTab: 'chat' })
stateService.fireBlurChat()
commandService.executeCommand(VOID_CMD_SHIFT_L_ACTION_ID)
}
})
@ -265,28 +250,28 @@ registerAction2(class extends Action2 {
export class TabSwitchListener extends Disposable {
// export class TabSwitchListener extends Disposable {
constructor(
onSwitchTab: () => void,
@ICodeEditorService private readonly _editorService: ICodeEditorService,
) {
super()
// constructor(
// onSwitchTab: () => void,
// @ICodeEditorService private readonly _editorService: ICodeEditorService,
// ) {
// super()
// when editor switches tabs (models)
const addTabSwitchListeners = (editor: ICodeEditor) => {
this._register(editor.onDidChangeModel(e => {
if (e.newModelUrl?.scheme !== 'file') return
onSwitchTab()
}))
}
// // when editor switches tabs (models)
// const addTabSwitchListeners = (editor: ICodeEditor) => {
// this._register(editor.onDidChangeModel(e => {
// if (e.newModelUrl?.scheme !== 'file') return
// onSwitchTab()
// }))
// }
const initializeEditor = (editor: ICodeEditor) => {
addTabSwitchListeners(editor)
}
// const initializeEditor = (editor: ICodeEditor) => {
// addTabSwitchListeners(editor)
// }
// initialize current editors + any new editors
for (let editor of this._editorService.listCodeEditors()) initializeEditor(editor)
this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) }))
}
}
// // initialize current editors + any new editors
// for (let editor of this._editorService.listCodeEditors()) initializeEditor(editor)
// this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) }))
// }
// }

View file

@ -1,82 +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 { ICommandService } from '../../../../platform/commands/common/commands.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { VOID_OPEN_SIDEBAR_ACTION_ID } from './sidebarPane.js';
// service that manages sidebar's state
export type VoidSidebarState = {
isHistoryOpen: boolean; // this isn't doing anything right now
currentTab: 'chat';
}
export interface ISidebarStateService {
readonly _serviceBrand: undefined;
readonly state: VoidSidebarState; // readonly to the user
setState(newState: Partial<VoidSidebarState>): void;
onDidChangeState: Event<void>;
onDidFocusChat: Event<void>;
onDidBlurChat: Event<void>;
fireFocusChat(): void;
fireBlurChat(): void;
}
export const ISidebarStateService = createDecorator<ISidebarStateService>('voidSidebarStateService');
class VoidSidebarStateService extends Disposable implements ISidebarStateService {
_serviceBrand: undefined;
static readonly ID = 'voidSidebarStateService';
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: VoidSidebarState
constructor(
@ICommandService private readonly commandService: ICommandService,
) {
super()
// initial state
this.state = { isHistoryOpen: false, currentTab: 'chat', }
}
setState(newState: Partial<VoidSidebarState>) {
// make sure view is open if the tab changes
if ('currentTab' in newState) {
this.commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
}
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
fireFocusChat() {
this._onFocusChat.fire()
}
fireBlurChat() {
this._onBlurChat.fire()
}
}
registerSingleton(ISidebarStateService, VoidSidebarStateService, InstantiationType.Eager);

View file

@ -12,7 +12,7 @@ import { LintErrorItem, ToolCallParams, ToolResultType } from '../common/toolsSe
import { IVoidModelService } from '../common/voidModelService.js'
import { EndOfLinePreference } from '../../../../editor/common/model.js'
import { IVoidCommandBarService } from './voidCommandBarService.js'
import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from './directoryStrService.js'
import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from '../common/directoryStrService.js'
import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'
import { timeout } from '../../../../base/common/async.js'
import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js'

View file

@ -10,7 +10,6 @@ import './editCodeService.js'
// register Sidebar pane, state, actions (keybinds, menus) (Ctrl+L)
import './sidebarActions.js'
import './sidebarPane.js'
import './sidebarStateService.js'
// register quick edit (Ctrl+K)
import './quickEditActions.js'

View file

@ -19,13 +19,15 @@ import { ITextModel } from '../../../../editor/common/model.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { VOID_ACCEPT_DIFF_ACTION_ID, VOID_REJECT_DIFF_ACTION_ID } from './actionIDs.js';
import { VOID_ACCEPT_DIFF_ACTION_ID, VOID_REJECT_DIFF_ACTION_ID, VOID_GOTO_NEXT_DIFF_ACTION_ID, VOID_GOTO_PREV_DIFF_ACTION_ID, VOID_GOTO_NEXT_URI_ACTION_ID, VOID_GOTO_PREV_URI_ACTION_ID, VOID_ACCEPT_FILE_ACTION_ID, VOID_REJECT_FILE_ACTION_ID, VOID_ACCEPT_ALL_DIFFS_ACTION_ID, VOID_REJECT_ALL_DIFFS_ACTION_ID } from './actionIDs.js';
import { localize2 } from '../../../../nls.js';
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
import { IMetricsService } from '../common/metricsService.js';
import { KeyMod } from '../../../../editor/common/services/editorBaseApi.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { ScrollType } from '../../../../editor/common/editorCommon.js';
import { IVoidModelService } from '../common/voidModelService.js';
@ -41,6 +43,11 @@ export interface IVoidCommandBarService {
getStreamState: (uri: URI) => 'streaming' | 'idle-has-changes' | 'idle-no-changes';
setDiffIdx(uri: URI, newIdx: number | null): void;
getNextDiffIdx(step: 1 | -1): number | null;
getNextUriIdx(step: 1 | -1): number | null;
goToDiffIdx(idx: number | null): void;
goToURIIdx(idx: number | null): Promise<void>;
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }): void;
anyFileIsStreaming(): boolean;
@ -93,6 +100,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
@IModelService private readonly _modelService: IModelService,
@IEditCodeService private readonly _editCodeService: IEditCodeService,
@IVoidModelService private readonly _voidModelService: IVoidModelService,
) {
super();
@ -374,6 +382,99 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
return this.sortedURIs.some(uri => this.getStreamState(uri) === 'streaming')
}
getNextDiffIdx(step: 1 | -1): number | null {
// If no active URI, return null
if (!this.activeURI) return null;
const state = this.stateOfURI[this.activeURI.fsPath];
if (!state) return null;
const { diffIdx, sortedDiffIds } = state;
// If no diffs, return null
if (sortedDiffIds.length === 0) return null;
// Calculate next index with wrapping
const nextIdx = ((diffIdx ?? 0) + step + sortedDiffIds.length) % sortedDiffIds.length;
return nextIdx;
}
getNextUriIdx(step: 1 | -1): number | null {
// If no URIs with changes, return null
if (this.sortedURIs.length === 0) return null;
// If no active URI, return first or last based on step
if (!this.activeURI) {
return step === 1 ? 0 : this.sortedURIs.length - 1;
}
// Find current index
const currentIdx = this.sortedURIs.findIndex(uri => uri.fsPath === this.activeURI?.fsPath);
// If not found, return first or last based on step
if (currentIdx === -1) {
return step === 1 ? 0 : this.sortedURIs.length - 1;
}
// Calculate next index with wrapping
const nextIdx = (currentIdx + step + this.sortedURIs.length) % this.sortedURIs.length;
return nextIdx;
}
goToDiffIdx(idx: number | null): void {
// If null or no active URI, return
if (idx === null || !this.activeURI) return;
// Get state for the current URI
const state = this.stateOfURI[this.activeURI.fsPath];
if (!state) return;
const { sortedDiffIds } = state;
// Find the diff at the specified index
const diffid = sortedDiffIds[idx];
if (diffid === undefined) return;
// Get the diff object
const diff = this._editCodeService.diffOfId[diffid];
if (!diff) return;
// Find an active editor to focus
const editor = this._codeEditorService.getFocusedCodeEditor() ||
this._codeEditorService.getActiveCodeEditor();
if (!editor) return;
// Reveal the line in the editor
editor.revealLineNearTop(diff.startLine - 1, ScrollType.Immediate);
// Update the current diff index
this.setDiffIdx(this.activeURI, idx);
}
async goToURIIdx(idx: number | null): Promise<void> {
// If null or no URIs, return
if (idx === null || this.sortedURIs.length === 0) return;
// Get the URI at the specified index
const nextURI = this.sortedURIs[idx];
if (!nextURI) return;
// Get the model for this URI
const { model } = await this._voidModelService.getModelSafe(nextURI);
if (!model) return;
// Find an editor to use
const editor = this._codeEditorService.getFocusedCodeEditor() ||
this._codeEditorService.getActiveCodeEditor();
if (!editor) return;
// Open the URI in the editor
await this._codeEditorService.openCodeEditor(
{ resource: model.uri, options: { revealIfVisible: true } },
editor
);
}
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }) {
const { behavior } = opts
// if anything is streaming, do nothing
@ -497,8 +598,13 @@ registerAction2(class extends Action2 {
if (!diffid) return;
metricsService.capture('Accept Diff', { diffid, keyboard: true });
editCodeService.acceptDiff({ diffid: parseInt(diffid) })
editCodeService.acceptDiff({ diffid: parseInt(diffid) });
// After accepting the diff, navigate to the next diff
const nextDiffIdx = commandBarService.getNextDiffIdx(1);
if (nextDiffIdx !== null) {
commandBarService.goToDiffIdx(nextDiffIdx);
}
}
});
@ -534,6 +640,232 @@ registerAction2(class extends Action2 {
if (!diffid) return;
metricsService.capture('Reject Diff', { diffid, keyboard: true });
editCodeService.rejectDiff({ diffid: parseInt(diffid) })
editCodeService.rejectDiff({ diffid: parseInt(diffid) });
// After rejecting the diff, navigate to the next diff
const nextDiffIdx = commandBarService.getNextDiffIdx(1);
if (nextDiffIdx !== null) {
commandBarService.goToDiffIdx(nextDiffIdx);
}
}
});
// Go to next diff action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_GOTO_NEXT_DIFF_ACTION_ID,
f1: true,
title: localize2('voidGoToNextDiffAction', 'Void: Go to Next Diff'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.DownArrow,
mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.DownArrow },
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
const nextDiffIdx = commandBarService.getNextDiffIdx(1);
if (nextDiffIdx === null) return;
metricsService.capture('Navigate Diff', { direction: 'next', keyboard: true });
commandBarService.goToDiffIdx(nextDiffIdx);
}
});
// Go to previous diff action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_GOTO_PREV_DIFF_ACTION_ID,
f1: true,
title: localize2('voidGoToPrevDiffAction', 'Void: Go to Previous Diff'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.UpArrow,
mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.UpArrow },
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
const prevDiffIdx = commandBarService.getNextDiffIdx(-1);
if (prevDiffIdx === null) return;
metricsService.capture('Navigate Diff', { direction: 'previous', keyboard: true });
commandBarService.goToDiffIdx(prevDiffIdx);
}
});
// Go to next URI action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_GOTO_NEXT_URI_ACTION_ID,
f1: true,
title: localize2('voidGoToNextUriAction', 'Void: Go to Next File with Diffs'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.RightArrow,
mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.RightArrow },
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
const nextUriIdx = commandBarService.getNextUriIdx(1);
if (nextUriIdx === null) return;
metricsService.capture('Navigate URI', { direction: 'next', keyboard: true });
await commandBarService.goToURIIdx(nextUriIdx);
}
});
// Go to previous URI action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_GOTO_PREV_URI_ACTION_ID,
f1: true,
title: localize2('voidGoToPrevUriAction', 'Void: Go to Previous File with Diffs'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift | KeyCode.LeftArrow,
mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.LeftArrow },
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
const prevUriIdx = commandBarService.getNextUriIdx(-1);
if (prevUriIdx === null) return;
metricsService.capture('Navigate URI', { direction: 'previous', keyboard: true });
await commandBarService.goToURIIdx(prevUriIdx);
}
});
// Accept current file action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_ACCEPT_FILE_ACTION_ID,
f1: true,
title: localize2('voidAcceptFileAction', 'Void: Accept All Diffs in Current File'),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.Enter,
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const editCodeService = accessor.get(IEditCodeService);
const metricsService = accessor.get(IMetricsService);
const activeURI = commandBarService.activeURI;
if (!activeURI) return;
metricsService.capture('Accept File', { keyboard: true });
editCodeService.acceptOrRejectAllDiffAreas({
uri: activeURI,
behavior: 'accept',
removeCtrlKs: true
});
}
});
// Reject current file action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_REJECT_FILE_ACTION_ID,
f1: true,
title: localize2('voidRejectFileAction', 'Void: Reject All Diffs in Current File'),
keybinding: {
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.Backspace,
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const editCodeService = accessor.get(IEditCodeService);
const metricsService = accessor.get(IMetricsService);
const activeURI = commandBarService.activeURI;
if (!activeURI) return;
metricsService.capture('Reject File', { keyboard: true });
editCodeService.acceptOrRejectAllDiffAreas({
uri: activeURI,
behavior: 'reject',
removeCtrlKs: true
});
}
});
// Accept all diffs in all files action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_ACCEPT_ALL_DIFFS_ACTION_ID,
f1: true,
title: localize2('voidAcceptAllDiffsAction', 'Void: Accept All Diffs in All Files'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter,
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
if (commandBarService.anyFileIsStreaming()) return;
metricsService.capture('Accept All Files', { keyboard: true });
commandBarService.acceptOrRejectAllFiles({ behavior: 'accept' });
}
});
// Reject all diffs in all files action
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_REJECT_ALL_DIFFS_ACTION_ID,
f1: true,
title: localize2('voidRejectAllDiffsAction', 'Void: Reject All Diffs in All Files'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Backspace,
weight: KeybindingWeight.VoidExtension,
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandBarService = accessor.get(IVoidCommandBarService);
const metricsService = accessor.get(IMetricsService);
if (commandBarService.anyFileIsStreaming()) return;
metricsService.capture('Reject All Files', { keyboard: true });
commandBarService.acceptOrRejectAllFiles({ behavior: 'reject' });
}
});

View file

@ -7,13 +7,10 @@ import { URI } from '../../../../base/common/uri.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IFileService, IFileStat } from '../../../../platform/files/common/files.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js';
import { IExplorerService } from '../../files/browser/files.js';
import { SortOrder } from '../../files/common/files.js';
import { ExplorerItem } from '../../files/common/explorerModel.js';
import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from '../common/prompt/prompts.js';
import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from './toolsServiceTypes.js';
import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from './prompt/prompts.js';
const MAX_FILES_TOTAL = 1000;
@ -28,8 +25,10 @@ const DEFAULT_MAX_ITEMS_PER_DIR = 3;
export interface IDirectoryStrService {
readonly _serviceBrand: undefined;
getDirectoryStrTool(uri: URI, options?: { maxItemsPerDir?: number }): Promise<string>
getAllDirectoriesStr(opts: { cutOffMessage: string, maxItemsPerDir?: number }): Promise<string>
getDirectoryStrTool(uri: URI): Promise<string>
getAllDirectoriesStr(opts: { cutOffMessage: string }): Promise<string>
getAllURIsInDirectory(uri: URI, opts: { maxResults: number }): Promise<URI[]>
}
export const IDirectoryStrService = createDecorator<IDirectoryStrService>('voidDirectoryStrService');
@ -38,35 +37,35 @@ export const IDirectoryStrService = createDecorator<IDirectoryStrService>('voidD
// Check if it's a known filtered type like .git
const shouldExcludeDirectory = (item: ExplorerItem) => {
if (item.name === '.git' ||
item.name === 'node_modules' ||
item.name.startsWith('.') ||
item.name === 'dist' ||
item.name === 'build' ||
item.name === 'out' ||
item.name === 'bin' ||
item.name === 'coverage' ||
item.name === '__pycache__' ||
item.name === 'env' ||
item.name === 'venv' ||
item.name === 'tmp' ||
item.name === 'temp' ||
item.name === 'artifacts' ||
item.name === 'target' ||
item.name === 'obj' ||
item.name === 'vendor' ||
item.name === 'logs' ||
item.name === 'cache' ||
item.name === 'resource' ||
item.name === 'resources'
const shouldExcludeDirectory = (name: string) => {
if (name === '.git' ||
name === 'node_modules' ||
name.startsWith('.') ||
name === 'dist' ||
name === 'build' ||
name === 'out' ||
name === 'bin' ||
name === 'coverage' ||
name === '__pycache__' ||
name === 'env' ||
name === 'venv' ||
name === 'tmp' ||
name === 'temp' ||
name === 'artifacts' ||
name === 'target' ||
name === 'obj' ||
name === 'vendor' ||
name === 'logs' ||
name === 'cache' ||
name === 'resource' ||
name === 'resources'
) {
return true;
}
if (item.name.match(/\bout\b/)) return true
if (item.name.match(/\bbuild\b/)) return true
if (name.match(/\bout\b/)) return true
if (name.match(/\bbuild\b/)) return true
return false;
}
@ -138,10 +137,16 @@ export const stringifyDirectoryTree1Deep = (params: ToolCallParams['ls_dir'], re
// ---------- IN GENERAL ----------
const resolveChildren = async (children: undefined | IFileStat[], fileService: IFileService): Promise<IFileStat[]> => {
const res = await fileService.resolveAll(children ?? [])
const stats = res.map(s => s.success ? s.stat : null).filter(s => !!s)
return stats
}
// Remove the old computeDirectoryTree function and replace with a combined version that handles both computation and rendering
const computeAndStringifyDirectoryTree = async (
eItem: ExplorerItem,
explorerService: IExplorerService,
eItem: IFileStat,
fileService: IFileService,
MAX_CHARS: number,
fileCount: { count: number } = { count: 0 },
options: { maxDepth?: number, currentDepth?: number, maxItemsPerDir?: number } = {}
@ -181,12 +186,13 @@ const computeAndStringifyDirectoryTree = async (
let remainingChars = MAX_CHARS - nodeLine.length;
// Check if it's a directory we should skip
const isGitIgnoredDirectory = eItem.isDirectory && shouldExcludeDirectory(eItem);
const isGitIgnoredDirectory = eItem.isDirectory && shouldExcludeDirectory(eItem.name);
// Fetch and process children if not a filtered directory
if (eItem.isDirectory && !isGitIgnoredDirectory) {
// Fetch children with Modified sort order to show recently modified first
const eChildren = await eItem.fetchChildren(SortOrder.Modified);
const eChildren = await resolveChildren(eItem.children, fileService)
// Then recursively add all children with proper tree formatting
if (eChildren && eChildren.length > 0) {
@ -194,7 +200,7 @@ const computeAndStringifyDirectoryTree = async (
eChildren,
remainingChars,
'',
explorerService,
fileService,
fileCount,
{ maxDepth, currentDepth, maxItemsPerDir } // Pass maxItemsPerDir to the render function
);
@ -208,10 +214,10 @@ const computeAndStringifyDirectoryTree = async (
// Helper function to render children with proper tree formatting
const renderChildrenCombined = async (
children: ExplorerItem[],
children: IFileStat[],
maxChars: number,
parentPrefix: string,
explorerService: IExplorerService,
fileService: IFileService,
fileCount: { count: number },
options: { maxDepth: number, currentDepth: number, maxItemsPerDir?: number }
): Promise<{ childrenContent: string, childrenCutOff: boolean }> => {
@ -263,12 +269,12 @@ const renderChildrenCombined = async (
const nextLevelPrefix = parentPrefix + (isLast ? ' ' : '│ ');
// Skip processing children for git ignored directories
const isGitIgnoredDirectory = child.isDirectory && shouldExcludeDirectory(child);
const isGitIgnoredDirectory = child.isDirectory && shouldExcludeDirectory(child.name);
// Create the prefix for the next level (continuation line or space)
if (child.isDirectory && !isGitIgnoredDirectory) {
// Fetch children with Modified sort order to show recently modified first
const eChildren = await child.fetchChildren(SortOrder.Modified);
const eChildren = await resolveChildren(child.children, fileService)
if (eChildren && eChildren.length > 0) {
const {
@ -278,7 +284,7 @@ const renderChildrenCombined = async (
eChildren,
remainingChars,
nextLevelPrefix,
explorerService,
fileService,
fileCount,
{ maxDepth, currentDepth: nextDepth, maxItemsPerDir }
);
@ -311,7 +317,68 @@ const renderChildrenCombined = async (
};
// ---------------------------------------------------
// ------------------------- FOLDERS -------------------------
export async function getAllUrisInDirectory(
directoryUri: URI,
maxResults: number,
fileService: IFileService,
): Promise<URI[]> {
const result: URI[] = [];
// Helper function to recursively collect URIs
async function visitAll(folderStat: IFileStat): Promise<boolean> {
// Stop if we've reached the limit
if (result.length >= maxResults) {
return false;
}
try {
if (!folderStat.isDirectory || !folderStat.children) {
return true;
}
const eChildren = await resolveChildren(folderStat.children, fileService)
// Process files first (common convention to list files before directories)
for (const child of eChildren) {
if (!child.isDirectory) {
result.push(child.resource);
// Check if we've hit the limit
if (result.length >= maxResults) {
return false;
}
}
}
// Then process directories recursively
for (const child of eChildren) {
const isGitIgnored = shouldExcludeDirectory(child.name)
if (child.isDirectory && !isGitIgnored) {
const shouldContinue = await visitAll(child);
if (!shouldContinue) {
return false;
}
}
}
return true;
} catch (error) {
console.error(`Error processing directory ${folderStat.resource.fsPath}: ${error}`);
return true; // Continue despite errors in a specific directory
}
}
const rootStat = await fileService.resolve(directoryUri)
await visitAll(rootStat);
return result;
}
// --------------------------------------------------
class DirectoryStrService extends Disposable implements IDirectoryStrService {
@ -319,21 +386,25 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
constructor(
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IExplorerService private readonly explorerService: IExplorerService,
@IFileService private readonly fileService: IFileService,
) {
super();
}
async getDirectoryStrTool(uri: URI, options?: { maxItemsPerDir?: number }) {
const eRoot = this.explorerService.findClosest(uri)
async getAllURIsInDirectory(uri: URI, opts: { maxResults: number }): Promise<URI[]> {
return getAllUrisInDirectory(uri, opts.maxResults, this.fileService)
}
async getDirectoryStrTool(uri: URI) {
const eRoot = await this.fileService.resolve(uri)
if (!eRoot) throw new Error(`The folder ${uri.fsPath} does not exist.`)
const maxItemsPerDir = options?.maxItemsPerDir ?? START_MAX_ITEMS_PER_DIR; // Use START_MAX_ITEMS_PER_DIR
const maxItemsPerDir = START_MAX_ITEMS_PER_DIR; // Use START_MAX_ITEMS_PER_DIR
// First try with START_MAX_DEPTH
const { content: initialContent, wasCutOff: initialCutOff } = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
this.fileService,
MAX_DIRSTR_CHARS_TOTAL_TOOL,
{ count: 0 },
{ maxDepth: START_MAX_DEPTH, currentDepth: 0, maxItemsPerDir }
@ -344,7 +415,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
if (initialCutOff) {
const result = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
this.fileService,
MAX_DIRSTR_CHARS_TOTAL_TOOL,
{ count: 0 },
{ maxDepth: DEFAULT_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: DEFAULT_MAX_ITEMS_PER_DIR }
@ -363,7 +434,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
return c
}
async getAllDirectoriesStr({ cutOffMessage, maxItemsPerDir }: { cutOffMessage: string, maxItemsPerDir?: number }) {
async getAllDirectoriesStr({ cutOffMessage, }: { cutOffMessage: string, }) {
let str: string = '';
let cutOff = false;
const folders = this.workspaceContextService.getWorkspace().folders;
@ -371,7 +442,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
return '(NO WORKSPACE OPEN)';
// Use START_MAX_ITEMS_PER_DIR if not specified
const startMaxItemsPerDir = maxItemsPerDir ?? START_MAX_ITEMS_PER_DIR;
const startMaxItemsPerDir = START_MAX_ITEMS_PER_DIR;
for (let i = 0; i < folders.length; i += 1) {
if (i > 0) str += '\n';
@ -381,13 +452,13 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
str += `Directory of ${f.uri.fsPath}:\n`;
const rootURI = f.uri;
const eRoot = this.explorerService.findClosestRoot(rootURI);
const eRoot = await this.fileService.resolve(rootURI)
if (!eRoot) continue;
// First try with START_MAX_DEPTH and startMaxItemsPerDir
const { content: initialContent, wasCutOff: initialCutOff } = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
this.fileService,
MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length,
{ count: 0 },
{ maxDepth: START_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: startMaxItemsPerDir }
@ -398,7 +469,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
if (initialCutOff) {
const result = await computeAndStringifyDirectoryTree(
eRoot,
this.explorerService,
this.fileService,
MAX_DIRSTR_CHARS_TOTAL_BEGINNING - str.length,
{ count: 0 },
{ maxDepth: DEFAULT_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: DEFAULT_MAX_ITEMS_PER_DIR }
@ -417,11 +488,8 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService {
}
}
if (cutOff) {
return `${str.trimEnd()}\n${cutOffMessage}`
}
return str
const ans = cutOff ? `${str.trimEnd()}\n${cutOffMessage}` : str
return ans
}
}

View file

@ -352,7 +352,7 @@ const openSourceModelOptions_assumingOAICompat = {
// keep modelName, but use the fallback's defaults
const extensiveOAICompatModelOptionsFallback: VoidStaticProviderInfo['modelOptionsFallback'] = (modelName, fallbackKnownValues) => {
const extensiveModelOptionsFallback: VoidStaticProviderInfo['modelOptionsFallback'] = (modelName, fallbackKnownValues) => {
const lower = modelName.toLowerCase()
@ -1018,32 +1018,32 @@ export const ollamaRecommendedModels = ['qwen2.5-coder:1.5b', 'llama3.1', 'qwq',
const vLLMSettings: VoidStaticProviderInfo = {
// reasoning: OAICompat + response.choices[0].delta.reasoning_content // https://docs.vllm.ai/en/stable/features/reasoning_outputs.html#streaming-chat-completions
providerReasoningIOSettings: { output: { nameOfFieldInDelta: 'reasoning_content' }, },
modelOptionsFallback: (modelName) => extensiveOAICompatModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: {}, // TODO
}
const lmStudioSettings: VoidStaticProviderInfo = {
providerReasoningIOSettings: { output: { needsManualParse: true }, },
modelOptionsFallback: (modelName) => extensiveOAICompatModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' }, contextWindow: 4_096 }),
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' }, contextWindow: 4_096 }),
modelOptions: {}, // TODO
}
const ollamaSettings: VoidStaticProviderInfo = {
// reasoning: we need to filter out reasoning <think> tags manually
providerReasoningIOSettings: { output: { needsManualParse: true }, },
modelOptionsFallback: (modelName) => extensiveOAICompatModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: ollamaModelOptions,
}
const openaiCompatible: VoidStaticProviderInfo = {
// reasoning: we have no idea what endpoint they used, so we can't consistently parse out reasoning
modelOptionsFallback: (modelName) => extensiveOAICompatModelOptionsFallback(modelName),
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName),
modelOptions: {},
}
const liteLLMSettings: VoidStaticProviderInfo = { // https://docs.litellm.ai/docs/reasoning_content
providerReasoningIOSettings: { output: { nameOfFieldInDelta: 'reasoning_content' } },
modelOptionsFallback: (modelName) => extensiveOAICompatModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptionsFallback: (modelName) => extensiveModelOptionsFallback(modelName, { downloadable: { sizeGb: 'not-known' } }),
modelOptions: {}, // TODO
}
@ -1197,11 +1197,11 @@ const openRouterSettings: VoidStaticProviderInfo = {
modelOptions: openRouterModelOptions_assumingOpenAICompat,
// TODO!!! send a query to openrouter to get the price, etc.
modelOptionsFallback: (modelName) => {
const res = extensiveOAICompatModelOptionsFallback(modelName)
// openRouter does not support gemini-style, use openai-style instead
if (res?.specialToolFormat === 'gemini-style') {
res.specialToolFormat = 'openai-style'
}
const res = extensiveModelOptionsFallback(modelName)
// // openRouter does not support gemini-style, use openai-style instead
// if (res?.specialToolFormat === 'gemini-style') {
// res.specialToolFormat = 'openai-style'
// }
return res
},
}

View file

@ -3,12 +3,13 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { EndOfLinePreference } from '../../../../../editor/common/model.js';
import { URI } from '../../../../../base/common/uri.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { IDirectoryStrService } from '../directoryStrService.js';
import { StagingSelectionItem } from '../chatThreadServiceTypes.js';
import { os } from '../helpers/systemInfo.js';
import { RawToolParamsObj } from '../sendLLMMessageTypes.js';
import { approvalTypeOfToolName, ToolCallParams, ToolResultType } from '../toolsServiceTypes.js';
import { IVoidModelService } from '../voidModelService.js';
import { ChatMode } from '../voidSettingsTypes.js';
// Triple backtick wrapper used throughout the prompts for code blocks
@ -524,40 +525,66 @@ ${details.map((d, i) => `${i + 1}. ${d}`).join('\n\n')}`)
// chat_systemMessage({ chatMode, workspaceFolders: [], openedURIs: [], activeURI: 'pee', persistentTerminalIDs: [], directoryStr: 'lol', }))
// }
const readFile = async (fileService: IFileService, uri: URI, fileSizeLimit: number): Promise<{
val: string,
truncated: boolean,
fullFileLen: number,
} | {
val: null,
truncated?: undefined
fullFileLen?: undefined,
}> => {
try {
const fileContent = await fileService.readFile(uri)
const val = fileContent.value.toString()
if (val.length > fileSizeLimit) return { val: val.substring(0, fileSizeLimit), truncated: true, fullFileLen: val.length }
return { val, truncated: false, fullFileLen: val.length }
}
catch (e) {
return { val: null }
}
}
export const chat_userMessageContent = async (instructions: string, currSelns: StagingSelectionItem[] | null,
opts: { type: 'references' } | { type: 'fullCode', voidModelService: IVoidModelService }
opts: { directoryStrService: IDirectoryStrService, fileService: IFileService }
) => {
const lineNumAddition = (range: [number, number]) => ` (lines ${range[0]}:${range[1]})`
let selnsStrs: string[] = []
if (opts.type === 'references') {
selnsStrs = currSelns?.map((s) => {
if (s.type === 'File') return `${s.uri.fsPath}`
if (s.type === 'CodeSelection') return `${s.uri.fsPath}${lineNumAddition(s.range)}`
if (s.type === 'Folder') return `${s.uri.fsPath}/`
return ''
}) ?? []
}
if (opts.type === 'fullCode') {
selnsStrs = await Promise.all(currSelns?.map(async (s) => {
if (s.type === 'File' || s.type === 'CodeSelection') {
const voidModelService = opts.voidModelService
const { model } = await voidModelService.getModelSafe(s.uri)
if (!model) return ''
const val = model.getValue(EndOfLinePreference.LF)
selnsStrs = await Promise.all(currSelns?.map(async (s) => {
const lineNumAdd = s.type === 'CodeSelection' ? lineNumAddition(s.range) : ''
const str = `${s.uri.fsPath}${lineNumAdd}\n${tripleTick[0]}${s.language}\n${val}\n${tripleTick[1]}`
if (s.type === 'File' || s.type === 'CodeSelection') {
const { val } = await readFile(opts.fileService, s.uri, 2_000_000)
const lineNumAdd = s.type === 'CodeSelection' ? lineNumAddition(s.range) : ''
const content = val === null ? 'null' : `${tripleTick[0]}${s.language}\n${val}\n${tripleTick[1]}`
const str = `${s.uri.fsPath}${lineNumAdd}:\n${content}`
return str
}
else if (s.type === 'Folder') {
const dirStr: string = await opts.directoryStrService.getDirectoryStrTool(s.uri)
const folderStructure = `${s.uri.fsPath} folder structure:${tripleTick[0]}\n${dirStr}\n${tripleTick[1]}`
const uris = await opts.directoryStrService.getAllURIsInDirectory(s.uri, { maxResults: 1_000 })
const strOfFiles = await Promise.all(uris.map(async uri => {
const { val, truncated } = await readFile(opts.fileService, uri, 100_000)
const truncationStr = truncated ? `\n... file truncated ...` : ''
const content = val === null ? 'null' : `${tripleTick[0]}\n${val}${truncationStr}\n${tripleTick[1]}`
const str = `${uri.fsPath}:\n${content}`
return str
}
if (s.type === 'Folder') {
// TODO
return ''
}
}))
const contentStr = [folderStructure, ...strOfFiles].join('\n\n')
return contentStr
}
else
return ''
}) ?? [])
}
}) ?? [])
const selnsStr = selnsStrs.join('\n') ?? ''
let str = ''

View file

@ -92,7 +92,7 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
return { title: 'Groq', }
}
else if (providerName === 'xAI') {
return { title: 'xAI', }
return { title: 'Grok (xAI)', }
}
else if (providerName === 'mistral') {
return { title: 'Mistral', }
@ -120,9 +120,9 @@ export const subTextMdOfProviderName = (providerName: ProviderName): string => {
if (providerName === 'openAICompatible') return `Use any provider that's OpenAI-compatible (use this for llama.cpp and more).`
if (providerName === 'googleVertex') return 'You must authenticate before using Vertex with Void. Read more about endpoints [here](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/call-vertex-using-openai-library), and regions [here](https://cloud.google.com/vertex-ai/docs/general/locations#available-regions).'
if (providerName === 'microsoftAzure') return 'Read more about endpoints [here](https://learn.microsoft.com/en-us/rest/api/aifoundry/model-inference/get-chat-completions/get-chat-completions?view=rest-aifoundry-model-inference-2024-05-01-preview&tabs=HTTP), and get your API key [here](https://learn.microsoft.com/en-us/azure/search/search-security-api-keys?tabs=rest-use%2Cportal-find%2Cportal-query#find-existing-keys).'
if (providerName === 'ollama') return 'If you would like to change this endpoint, please read more about [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).'
if (providerName === 'vLLM') return 'If you would like to change this endpoint, please read more about [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).'
if (providerName === 'lmStudio') return 'If you would like to change this endpoint, please more about [Endpoints here](https://lmstudio.ai/docs/app/api/endpoints/openai).'
if (providerName === 'ollama') return 'Read more about custom [Endpoints here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-can-i-expose-ollama-on-my-network).'
if (providerName === 'vLLM') return 'Read more about custom [Endpoints here](https://docs.vllm.ai/en/latest/getting_started/quickstart.html#openai-compatible-server).'
if (providerName === 'lmStudio') return 'Read more about custom [Endpoints here](https://lmstudio.ai/docs/app/api/endpoints/openai).'
if (providerName === 'liteLLM') return 'Read more about endpoints [here](https://docs.litellm.ai/docs/providers/openai_compatible).'
throw new Error(`subTextMdOfProviderName: Unknown provider name: "${providerName}"`)