From 7df49a769aff9d0955818402ba6b6ed43495b0fd Mon Sep 17 00:00:00 2001 From: Mathew Pareles Date: Mon, 21 Apr 2025 02:25:37 -0700 Subject: [PATCH] add @ to mention! --- .../contrib/void/browser/chatThreadService.ts | 37 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 27 +- .../void/browser/react/src/util/inputs.tsx | 538 +++++++++++++----- .../void/browser/react/src/util/services.tsx | 2 + .../src/void-onboarding/VoidOnboarding.tsx | 24 +- .../contrib/void/browser/sidebarActions.ts | 56 +- .../contrib/void/browser/toolsService.ts | 1 + 7 files changed, 478 insertions(+), 207 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 7a09327d..baae23e4 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -41,7 +41,7 @@ const CHAT_RETRIES = 3 const RETRY_DELAY = 2500 -export const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => { +const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => { if (!currentSelections) return null for (let i = 0; i < currentSelections.length; i += 1) { @@ -203,6 +203,8 @@ export interface IChatThreadService { isCurrentlyFocusingMessage(): boolean; setCurrentlyFocusedMessageIdx(messageIdx: number | undefined): void; + addNewStagingSelection(newSelection: StagingSelectionItem): void; + dangerousSetState: (newState: ThreadsState) => void; resetState: () => void; @@ -1517,6 +1519,39 @@ We only need to do it for files that were edited since `from`, ie files between // this.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true }) } + + addNewStagingSelection(newSelection: StagingSelectionItem): void { + + const focusedMessageIdx = this.getCurrentFocusedMessageIdx() + + // set the selections to the proper value + let selections: StagingSelectionItem[] = [] + let setSelections = (s: StagingSelectionItem[]) => { } + + if (focusedMessageIdx === undefined) { + selections = this.getCurrentThreadState().stagingSelections + setSelections = (s: StagingSelectionItem[]) => this.setCurrentThreadState({ stagingSelections: s }) + } else { + selections = this.getCurrentMessageState(focusedMessageIdx).stagingSelections + setSelections = (s) => this.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) + } + + // if matches with existing selection, overwrite (since text may change) + const idx = findStagingSelectionIndex(selections, newSelection) + if (idx !== null && idx !== -1) { + setSelections([ + ...selections!.slice(0, idx), + newSelection, + ...selections!.slice(idx + 1, Infinity) + ]) + } + // if no match, add it + else { + setSelections([...(selections ?? []), newSelection]) + } + } + + // set message.state private _setCurrentMessageState(state: Partial, messageIdx: number): void { diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 70e4000e..60441371 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -20,7 +20,7 @@ import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; import { ChatMode, displayInfoOfProviderName, FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; import { WarningBox } from '../void-settings-tsx/WarningBox.js'; import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../common/modelCapabilities.js'; -import { AlertTriangle, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X, Flag, Copy as CopyIcon, Info, CirclePlus, Ellipsis, CircleEllipsis } from 'lucide-react'; +import { AlertTriangle, File, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X, Flag, Copy as CopyIcon, Info, CirclePlus, Ellipsis, CircleEllipsis, Folder, ALargeSmall, TypeOutline, Text } from 'lucide-react'; import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js'; import { approvalTypeOfToolName, LintErrorItem, ToolApprovalType, toolApprovalTypes, ToolCallParams } from '../../../../common/toolsServiceTypes.js'; import { ApplyButtonsHTML, CopyButton, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyButtonState } from '../markdown/ApplyBlockHoverButtons.js'; @@ -492,12 +492,12 @@ export const getFolderName = (pathStr: string) => { return lastTwo.join('/') + '/' } -export const getBasename = (pathStr: string) => { +export const getBasename = (pathStr: string, parts: number = 1) => { // 'unixify' path pathStr = pathStr.replace(/[/\\]+/g, '/') // replace any / or \ or \\ with / - const parts = pathStr.split('/') // split on / - if (parts.length === 0) return pathStr - return parts[parts.length - 1] + const allParts = pathStr.split('/') // split on / + if (allParts.length === 0) return pathStr + return allParts.slice(-parts).join('/') } export const SelectedFiles = ( @@ -576,6 +576,13 @@ export const SelectedFiles = ( : selection.type === 'Folder' ? selection.type + selection.language + selection.state + selection.uri.fsPath : i + const SelectionIcon = ( + selection.type === 'File' ? File + : selection.type === 'Folder' ? Folder + : selection.type === 'CodeSelection' ? Text + : (undefined as never) + ) + return
+ {} + { // file name and range getBasename(selection.uri.fsPath) + (selection.type === 'CodeSelection' ? ` (${selection.range[0]}-${selection.range[1]})` : '') } {selection.type === 'File' && selection.state.wasAddedAsCurrentFile && messageIdx === undefined && currentURI?.fsPath === selection.uri.fsPath ? - + {`(Current File)`} : null @@ -972,6 +981,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr setSelections={setStagingSelections} > { onClickAnywhere={() => { textAreaRef.current?.focus() }} > { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }} diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index 91281214..4b8375da 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { forwardRef, MutableRefObject, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, ForwardRefExoticComponent, MutableRefObject, RefAttributes, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { IInputBoxStyles, InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { SelectBox } from '../../../../../../../base/browser/ui/selectBox/selectBox.js'; @@ -16,6 +16,10 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { inputBackground, inputForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { useFloating, autoUpdate, offset, flip, shift, size, autoPlacement } from '@floating-ui/react'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { getBasename } from '../sidebar-tsx/SidebarChat.js'; +import { ChevronRight, File, Folder, FolderClosed, LucideProps } from 'lucide-react'; +import { StagingSelectionItem } from '../../../../common/chatThreadServiceTypes.js'; // type guard @@ -48,48 +52,233 @@ export const WidgetComponent = ({ ctor, prop return
{children}
} -type GenerateNextOptions = (newPathText: string) => Option[] +type GenerateNextOptions = (optionText: string) => Promise type Option = { - name: string, - displayName: string, + nameInMenu: string, + iconInMenu: ForwardRefExoticComponent & RefAttributes>, // type for lucide-react components } & ( - | { nextOptions: Option[], generateNextOptions?: undefined } - | { nextOptions?: undefined, generateNextOptions: GenerateNextOptions } - | { nextOptions?: undefined, generateNextOptions?: undefined } + | { nextOptions: Option[], generateNextOptions?: undefined, nameToPaste?: undefined } + | { nextOptions?: undefined, generateNextOptions: GenerateNextOptions, nameToPaste?: undefined } + | { leafNodeType: 'File' | 'Folder', nameToPaste: string, uri: URI, nextOptions?: undefined, generateNextOptions?: undefined, } ) -const getOptionsAtPath = (accessor: ReturnType, path: string[], newPathText: string) => { +const isSubsequence = (text: string, pattern: string): boolean => { + + text = text.toLowerCase() + pattern = pattern.toLowerCase() + + if (pattern === '') return true; + if (text === '') return false; + if (pattern.length > text.length) return false; + + const seq: boolean[][] = Array(pattern.length + 1) + .fill(null) + .map(() => Array(text.length + 1).fill(false)); + + for (let j = 0; j <= text.length; j++) { + seq[0][j] = true; + } + + for (let i = 1; i <= pattern.length; i++) { + for (let j = 1; j <= text.length; j++) { + if (pattern[i - 1] === text[j - 1]) { + seq[i][j] = seq[i - 1][j - 1]; + } else { + seq[i][j] = seq[i][j - 1]; + } + } + } + return seq[pattern.length][text.length]; +}; + + +const scoreSubsequence = (text: string, pattern: string): number => { + if (pattern === '') return 0; + + text = text.toLowerCase(); + pattern = pattern.toLowerCase(); + + // We'll use dynamic programming to find the longest consecutive substring + const n = text.length; + const m = pattern.length; + + // This will track our maximum consecutive match length + let maxConsecutive = 0; + + // For each starting position in the text + for (let i = 0; i < n; i++) { + // Check for matches starting from this position + let consecutiveCount = 0; + + // For each character in the pattern + for (let j = 0; j < m; j++) { + // If we have a match and we're still within text bounds + if (i + j < n && text[i + j] === pattern[j]) { + consecutiveCount++; + } else { + // Break on first non-match + break; + } + } + + // Update our maximum + maxConsecutive = Math.max(maxConsecutive, consecutiveCount); + } + + return maxConsecutive; +} + + +export function getRelativeWorkspacePath(accessor: ReturnType, uri: URI): string { + const workspaceService = accessor.get('IWorkspaceContextService'); + const workspaceFolders = workspaceService.getWorkspace().folders; + + if (!workspaceFolders.length) { + return uri.fsPath; // No workspace folders, return original path + } + + // Sort workspace folders by path length (descending) to match the most specific folder first + const sortedFolders = [...workspaceFolders].sort((a, b) => + b.uri.fsPath.length - a.uri.fsPath.length + ); + + // Add trailing slash to paths for exact matching + const uriPath = uri.fsPath.endsWith('/') ? uri.fsPath : uri.fsPath + '/'; + + // Check if the URI is inside any workspace folder + for (const folder of sortedFolders) { + + + const folderPath = folder.uri.fsPath.endsWith('/') ? folder.uri.fsPath : folder.uri.fsPath + '/'; + if (uriPath.startsWith(folderPath)) { + // Calculate the relative path by removing the workspace folder path + let relativePath = uri.fsPath.slice(folder.uri.fsPath.length); + // Remove leading slash if present + if (relativePath.startsWith('/')) { + relativePath = relativePath.slice(1); + } + console.log({ folderPath, relativePath, uriPath }); + + return relativePath; + } + } + + // URI is not in any workspace folder, return original path + return uri.fsPath; +} + + + +const numOptionsToShow = 100 + +const getOptionsAtPath = async (accessor: ReturnType, path: string[], optionText: string): Promise => { + + const toolsService = accessor.get('IToolsService') + + const searchForFilesOrFolders = async (t: string, searchFor: 'files' | 'folders') => { + try { + + const searchResults = (await (await toolsService.callTool.search_pathnames_only({ + query: t, + includePattern: null, + pageNumber: 1, + })).result).uris + + if (searchFor === 'files') { + const res: Option[] = searchResults.map(uri => { + const relativePath = getRelativeWorkspacePath(accessor, uri) + return { + leafNodeType: 'File', + uri: uri, + iconInMenu: File, + nameInMenu: relativePath, + nameToPaste: getBasename(relativePath, 2), + } + }) + return res + } + + else if (searchFor === 'folders') { + // Extract unique directory paths from the results + const directoryMap = new Map(); + + for (const uri of searchResults) { + if (!uri) continue; + + // Get the full path and extract directories + const relativePath = getRelativeWorkspacePath(accessor, uri) + const pathParts = relativePath.split('/'); + + // Get workspace info + const workspaceService = accessor.get('IWorkspaceContextService'); + const workspaceFolders = workspaceService.getWorkspace().folders; + + // Find the workspace folder containing this URI + let workspaceFolderUri: URI | undefined; + if (workspaceFolders.length) { + // Sort workspace folders by path length (descending) to match the most specific folder first + const sortedFolders = [...workspaceFolders].sort((a, b) => + b.uri.fsPath.length - a.uri.fsPath.length + ); + + // Find the containing workspace folder + for (const folder of sortedFolders) { + const folderPath = folder.uri.fsPath.endsWith('/') ? folder.uri.fsPath : folder.uri.fsPath + '/'; + const uriPath = uri.fsPath.endsWith('/') ? uri.fsPath : uri.fsPath + '/'; + + if (uriPath.startsWith(folderPath)) { + workspaceFolderUri = folder.uri; + break; + } + } + } + + if (workspaceFolderUri) { + // Add each directory and its parents to the map + let currentPath = ''; + 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( + workspaceFolderUri, + currentPath.startsWith('/') ? currentPath.substring(1) : currentPath + ); + + directoryMap.set(currentPath, directoryUri); + } + } + } + // Convert map to array + return Array.from(directoryMap.entries()).map(([relativePath, uri]) => ({ + leafNodeType: 'Folder', + uri: uri, + iconInMenu: Folder, // Folder + nameInMenu: relativePath, + nameToPaste: getBasename(relativePath, 2) + })) satisfies Option[]; + } + } catch (error) { + console.error('Error fetching directories:', error); + return []; + } + }; const allOptions: Option[] = [ { - name: 'files', - displayName: 'files', - generateNextOptions: () => [ - { name: 'a.txt', displayName: 'a.txt', }, - { name: 'b.txt', displayName: 'b.txt', }, - { name: 'c.txt', displayName: 'c.txt', }, - { name: 'd.txt', displayName: 'd.txt', }, - { name: 'e.txt', displayName: 'e.txt', }, - { name: 'f.txt', displayName: 'f.txt', }, - { name: 'g.txt', displayName: 'g.txt', }, - { name: '!a.txt', displayName: '!a.txt', }, - { name: '!b.txt', displayName: '!b.txt', }, - { name: '!c.txt', displayName: '!c.txt', }, - { name: '!d.txt', displayName: '!d.txt', }, - { name: '!e.txt', displayName: '!e.txt', }, - { name: '!f.txt', displayName: '!f.txt', }, - { name: '!g.txt', displayName: '!g.txt', }, - ] + nameInMenu: 'files', + iconInMenu: File, + generateNextOptions: async (t) => (await searchForFilesOrFolders(t, 'files')) || [], }, { - name: 'folders', - displayName: 'folders', - nextOptions: [ - { name: 'FOLDER', displayName: 'FOLDER', }, - ] + nameInMenu: 'folders', + iconInMenu: FolderClosed, + generateNextOptions: async (t) => (await searchForFilesOrFolders(t, 'folders')) || [], }, ] @@ -100,9 +289,9 @@ const getOptionsAtPath = (accessor: ReturnType, path: string for (const pn of path) { - const selectedOption = nextOptionsAtPath.find(o => o.name.toLowerCase() === pn.toLowerCase()) + const selectedOption = nextOptionsAtPath.find(o => o.nameInMenu.toLowerCase() === pn.toLowerCase()) - if (!selectedOption) return; + if (!selectedOption) return []; nextOptionsAtPath = selectedOption.nextOptions! // assume nextOptions exists until we hit the very last option (the path will never contain the last possible option) generateNextOptionsAtPath = selectedOption.generateNextOptions @@ -111,11 +300,17 @@ const getOptionsAtPath = (accessor: ReturnType, path: string if (generateNextOptionsAtPath) { - nextOptionsAtPath = generateNextOptionsAtPath(newPathText) + nextOptionsAtPath = await generateNextOptionsAtPath(optionText) } - const optionsAtPath = nextOptionsAtPath.filter(o => o.name.includes(newPathText)) - + const optionsAtPath = nextOptionsAtPath + .filter(o => isSubsequence(o.nameInMenu, optionText)) + .sort((a, b) => { // this is a hack but good for now + const scoreA = scoreSubsequence(a.nameInMenu, optionText); + const scoreB = scoreSubsequence(b.nameInMenu, optionText); + return scoreB - scoreA; + }) + .slice(0, numOptionsToShow) // should go last because sorting/filtering should happen on all datapoints return optionsAtPath @@ -128,6 +323,7 @@ type InputBox2Props = { initValue?: string | null; placeholder: string; multiline: boolean; + enableAtToMention?: boolean; fnsRef?: { current: null | TextAreaFns }; className?: string; onChangeText?: (value: string) => void; @@ -136,34 +332,28 @@ type InputBox2Props = { onBlur?: (e: React.FocusEvent) => void; onChangeHeight?: (newHeight: number) => void; } -export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { +export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, enableAtToMention, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { // mirrors whatever is in ref const accessor = useAccessor() - const toolsService = accessor.get('IToolsService') - - - - - - - - - - + const chatThreadService = accessor.get('IChatThreadService') + const languageService = accessor.get('ILanguageService') const textAreaRef = useRef(null) const selectedOptionRef = useRef(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuOpen, _setIsMenuOpen] = useState(false); // the @ to mention menu + const setIsMenuOpen: typeof _setIsMenuOpen = (value) => { + if (!enableAtToMention) { return; } // never open menu if not enabled + _setIsMenuOpen(value); + } - const [path, setPath] = useState([]); + // logic for @ to mention vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv + const [optionPath, setOptionPath] = useState([]); const [optionIdx, setOptionIdx] = useState(0); const [options, setOptions] = useState([]); - const [newPathText, setNewPathText] = useState(''); - - + const [optionText, setOptionText] = useState(''); const insertTextAtCursor = (text: string) => { const textarea = textAreaRef.current; if (!textarea) return; @@ -184,68 +374,142 @@ export const VoidInputBox2 = forwardRef(fun }; - - const onSelectOption = () => { + const onSelectOption = async () => { if (!options.length) { return; } const option = options[optionIdx]; - const newPath = [...path, option.name] + const newPath = [...optionPath, option.nameInMenu] const isLastOption = !option.generateNextOptions && !option.nextOptions - setPath(newPath) - setNewPathText('') + setOptionPath(newPath) + setOptionText('') setOptionIdx(0) if (isLastOption) { setIsMenuOpen(false) - insertTextAtCursor(`TODO-${option.displayName}`) + insertTextAtCursor(option.nameToPaste) + + const newSelection: StagingSelectionItem = option.leafNodeType === 'File' ? { + type: 'File', + uri: option.uri, + language: languageService.guessLanguageIdByFilepathOrFirstLine(option.uri) || '', + state: { wasAddedAsCurrentFile: false } + } : option.leafNodeType === 'Folder' ? { + type: 'Folder', + uri: option.uri, + language: undefined, + state: undefined, + } : (undefined as never) + chatThreadService.addNewStagingSelection(newSelection) + console.log('selected', option.uri?.fsPath) } else { - setOptions(getOptionsAtPath(accessor, newPath, '') || []) + const newOpts = await getOptionsAtPath(accessor, newPath, '') || [] + setOptions(newOpts) } } - const onRemoveOption = () => { - const newPath = [...path.slice(0, path.length - 1)] - setPath(newPath) - setNewPathText('') + const onRemoveOption = async () => { + const newPath = [...optionPath.slice(0, optionPath.length - 1)] + setOptionPath(newPath) + setOptionText('') setOptionIdx(0) - setOptions(getOptionsAtPath(accessor, newPath, '') || []) + const newOpts = await getOptionsAtPath(accessor, newPath, '') || [] + setOptions(newOpts) } - const onOpenOptionMenu = () => { - setPath([]) - setNewPathText('') + const onOpenOptionMenu = async () => { + setOptionPath([]) + setOptionText('') setIsMenuOpen(true); setOptionIdx(0); - setOptions(getOptionsAtPath(accessor, [], '') || []); + const newOpts = await getOptionsAtPath(accessor, [], '') || [] + setOptions(newOpts); } const onCloseOptionMenu = () => { setIsMenuOpen(false); } - const onNavigateUp = () => { + const onNavigateUp = (step = 1, periodic = true) => { if (options.length === 0) return; - setOptionIdx((prevIdx) => (prevIdx - 1 + options.length) % options.length); + setOptionIdx((prevIdx) => { + const newIdx = prevIdx - step; + return periodic ? (newIdx + options.length) % options.length : Math.max(0, newIdx); + }); } - const onNavigateDown = () => { + const onNavigateDown = (step = 1, periodic = true) => { if (options.length === 0) return; - setOptionIdx((prevIdx) => (prevIdx + 1) % options.length); + setOptionIdx((prevIdx) => { + const newIdx = prevIdx + step; + return periodic ? newIdx % options.length : Math.min(options.length - 1, newIdx); + }); } - const onPathTextChange = (newStr: string) => { - setNewPathText(newStr); - setOptions(getOptionsAtPath(accessor, path, newStr) || []); - + const onNavigateToTop = () => { + if (options.length === 0) return; + setOptionIdx(0); } + const onNavigateToBottom = () => { + if (options.length === 0) return; + setOptionIdx(options.length - 1); + } + + const debounceTimerRef = useRef(null); + + useEffect(() => { + // Cleanup function to cancel any pending timeouts when unmounting + return () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, []); + + // debounced + const onPathTextChange = useCallback((newStr: string) => { + + setOptionText(newStr); + + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + + // Set a new timeout to fetch options after a delay + debounceTimerRef.current = window.setTimeout(async () => { + const newOpts = await getOptionsAtPath(accessor, optionPath, newStr) || []; + setOptions(newOpts); + setOptionIdx(0); + debounceTimerRef.current = null; + }, 300); + }, [optionPath, accessor]); const onMenuKeyDown = (e: React.KeyboardEvent) => { + + const isCommandKeyPressed = e.altKey || e.ctrlKey || e.metaKey; + if (e.key === 'ArrowUp') { - onNavigateUp(); + if (isCommandKeyPressed) { + onNavigateToTop() + } else { + if (e.altKey) { + onNavigateUp(10, false); + } else { + onNavigateUp(); + } + } } else if (e.key === 'ArrowDown') { - onNavigateDown(); + if (isCommandKeyPressed) { + onNavigateToBottom() + } else { + if (e.altKey) { + onNavigateDown(10, false); + } else { + onNavigateDown(); + } + } } else if (e.key === 'ArrowLeft') { - onSelectOption(); + onRemoveOption(); } else if (e.key === 'ArrowRight') { onSelectOption(); } else if (e.key === 'Enter') { @@ -254,25 +518,26 @@ export const VoidInputBox2 = forwardRef(fun onCloseOptionMenu() } else if (e.key === 'Backspace') { - if (!newPathText) { // No text remaining - if (path.length === 0) { + if (!optionText) { // No text remaining + if (optionPath.length === 0) { onCloseOptionMenu() + return; // don't prevent defaults (backspaces the @ symbol) } else { onRemoveOption(); } } - else if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+Backspace + else if (isCommandKeyPressed) { // Ctrl+Backspace onPathTextChange('') } else { // Backspace - onPathTextChange(newPathText.slice(0, -1)) + onPathTextChange(optionText.slice(0, -1)) } } else if (e.key.length === 1) { - if (e.altKey || e.ctrlKey || e.metaKey) { // Ctrl+letter + if (isCommandKeyPressed) { // Ctrl+letter // do nothing } else { // letter - onPathTextChange(newPathText + e.key) + onPathTextChange(optionText + e.key) } } @@ -281,7 +546,7 @@ export const VoidInputBox2 = forwardRef(fun }; - // scroll the selected optionIdx into view on optionIdx and newPathText changes + // scroll the selected optionIdx into view on optionIdx and optionText changes useEffect(() => { if (isMenuOpen && selectedOptionRef.current) { selectedOptionRef.current.scrollIntoView({ @@ -290,9 +555,7 @@ export const VoidInputBox2 = forwardRef(fun inline: 'nearest', }); } - }, [optionIdx, isMenuOpen, newPathText, selectedOptionRef]); - - + }, [optionIdx, isMenuOpen, optionText, selectedOptionRef]); const measureRef = useRef(null); const gapPx = 2 @@ -307,7 +570,7 @@ export const VoidInputBox2 = forwardRef(fun } = useFloating({ open: isMenuOpen, onOpenChange: setIsMenuOpen, - placement: 'top', + placement: 'bottom', middleware: [ offset({ mainAxis: gapPx, crossAxis: offsetPx }), @@ -320,13 +583,9 @@ export const VoidInputBox2 = forwardRef(fun padding: 8, }), size({ - apply({ availableHeight, elements, rects }) { - const maxHeight = Math.min(availableHeight) - + apply({ elements, rects }) { + // Just set width on the floating element and let content handle scrolling Object.assign(elements.floating.style, { - maxHeight: `${maxHeight}px`, - overflowY: 'auto', - // Ensure the width isn't constrained by the parent width: `${Math.max( rects.reference.width, measureRef.current?.offsetWidth ?? 0 @@ -364,7 +623,7 @@ export const VoidInputBox2 = forwardRef(fun document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isMenuOpen, refs.floating, refs.reference]); - + // logic for @ to mention ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ const [isEnabled, setEnabled] = useState(true) @@ -467,11 +726,10 @@ export const VoidInputBox2 = forwardRef(fun rows={1} placeholder={placeholder} /> -
{`idx ${optionIdx}`}
{isMenuOpen && (
(fun }} onWheel={(e) => e.stopPropagation()} > -
- {/* Path navigation breadcrumbs */} -
- {[...path, newPathText].join(' > ')} + {/* Breadcrumbs Header */} +
+ {optionPath.length || optionText ? +
+ {optionPath.map((path, index) => ( + + {path} + + + ))} + {optionText} +
+ :
Enter text to filter...
+ } +
+ + + {/* Options list */} +
+
+ {options.length === 0 ? +
No results found
+ : options.map((o, oIdx) => { + + return ( + // Option +
{ onSelectOption(); }} + onMouseOver={() => { setOptionIdx(oIdx) }} + > + {} + {o.nameInMenu} + {o.nextOptions || o.generateNextOptions ? ( + + ) : null} +
+ ) + }) + }
- - {/* Options list */} - {options.length === 0 ? ( -
No options available
- ) : ( - options.map((o, oIdx) => ( -
{ onSelectOption(); }} - > -
- {o.displayName} - {o.nextOptions || o.generateNextOptions ? ( - - - - ) : null} -
-
- )) - )}
)} diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index a38f6401..6bd07485 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -49,6 +49,7 @@ import { INativeHostService } from '../../../../../../../platform/native/common/ import { IEditCodeService } from '../../../editCodeServiceInterface.js' import { IToolsService } from '../../../toolsService.js' import { IConvertToLLMMessageService } from '../../../convertToLLMMessageService.js' +import { ISearchService } from '../../../../../../services/search/common/search.js' // normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes @@ -204,6 +205,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => { ILanguageDetectionService: accessor.get(ILanguageDetectionService), ILanguageFeaturesService: accessor.get(ILanguageFeaturesService), IKeybindingService: accessor.get(IKeybindingService), + ISearchService: accessor.get(ISearchService), IExplorerService: accessor.get(IExplorerService), IEnvironmentService: accessor.get(IEnvironmentService), diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx index 87eafad0..8281fc77 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-onboarding/VoidOnboarding.tsx @@ -465,6 +465,7 @@ const VoidOnboardingContent = () => { const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') + const voidMetricsService = accessor.get('IMetricsService') const voidSettingsState = useSettingsState() @@ -480,6 +481,7 @@ const VoidOnboardingContent = () => { const [selectedPrivateProvider, setSelectedPrivateProvider] = useState('ollama'); const [selectedAffordableProvider, setSelectedAffordableProvider] = useState('gemini'); const [selectedAllProvider, setSelectedAllProvider] = useState('anthropic'); + const [didDoubleClickSkip, setDidDoubleClickSkip] = useState(false) // Helper function to get the current selected provider based on active tab const getSelectedProvider = (): ProviderName => { @@ -535,7 +537,10 @@ const VoidOnboardingContent = () => { onClick={() => { setPageIndex(pageIndex - 1) }} /> { voidSettingsService.setGlobalSetting('isOnboardingComplete', true); }} + onClick={() => { + voidSettingsService.setGlobalSetting('isOnboardingComplete', true); + voidMetricsService.capture('Completed Onboarding', { selectedProviderName, wantToUseOption }) + }} ringSize={voidSettingsState.globalSettings.isOnboardingComplete ? 'screen' : undefined} >Enter the Void
@@ -618,15 +623,16 @@ const VoidOnboardingContent = () => {
+
diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index d6c85982..095e0e07 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -30,31 +30,6 @@ import { getActiveWindow } from '../../../../base/browser/dom.js'; // ---------- Register commands and keybindings ---------- -const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => { - if (!currentSelections) return null - - for (let i = 0; i < currentSelections.length; i += 1) { - const s = currentSelections[i] - - if (s.uri.fsPath !== newSelection.uri.fsPath) continue - - if (s.type === 'File' && newSelection.type === 'File') { - return i - } - if (s.type === 'CodeSelection' && newSelection.type === 'CodeSelection') { - if (s.uri.fsPath !== newSelection.uri.fsPath) continue - // if there's any collision return true - const [oldStart, oldEnd] = s.range - const [newStart, newEnd] = newSelection.range - if (oldStart !== newStart || oldEnd !== newEnd) continue - return i - } - if (s.type === 'Folder' && newSelection.type === 'Folder') { - return i - } - } - return null -} export const roundRangeToLines = (range: IRange | null | undefined, options: { emptySelectionBehavior: 'null' | 'line' }) => { if (!range) @@ -104,8 +79,6 @@ registerAction2(class extends Action2 { }) - - // 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 { @@ -147,36 +120,9 @@ registerAction2(class extends Action2 { state: { wasAddedAsCurrentFile: false } } - // update the staging selections const chatThreadService = accessor.get(IChatThreadService) - const focusedMessageIdx = chatThreadService.getCurrentFocusedMessageIdx() - - // set the selections to the proper value - let selections: StagingSelectionItem[] = [] - let setSelections = (s: StagingSelectionItem[]) => { } - - if (focusedMessageIdx === undefined) { - selections = chatThreadService.getCurrentThreadState().stagingSelections - setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadState({ stagingSelections: s }) - } else { - selections = chatThreadService.getCurrentMessageState(focusedMessageIdx).stagingSelections - setSelections = (s) => chatThreadService.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) - } - - // if matches with existing selection, overwrite (since text may change) - const idx = findStagingSelectionIndex(selections, newSelection) - if (idx !== null && idx !== -1) { - setSelections([ - ...selections!.slice(0, idx), - newSelection, - ...selections!.slice(idx + 1, Infinity) - ]) - } - // if no match, add it - else { - setSelections([...(selections ?? []), newSelection]) - } + chatThreadService.addNewStagingSelection(newSelection) } }); diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index bdacb18e..be394b7b 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -308,6 +308,7 @@ export class ToolsService implements IToolsService { const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, includePattern: includePattern ?? undefined, + sortByScore: true, // makes results 10x better }) const data = await searchService.fileSearch(query, CancellationToken.None)