add @ to mention!

This commit is contained in:
Mathew Pareles 2025-04-21 02:25:37 -07:00
parent e3a59057a5
commit 7df49a769a
7 changed files with 478 additions and 207 deletions

View file

@ -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<UserMessageState>, messageIdx: number): void {

View file

@ -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 <div // container for summarybox and code
key={thisKey}
className={`flex flex-col space-y-[1px]`}
@ -583,7 +590,7 @@ export const SelectedFiles = (
{/* summarybox */}
<div
className={`
flex items-center gap-0.5 relative
flex items-center gap-1 relative
px-1
w-fit h-fit
select-none
@ -631,13 +638,15 @@ export const SelectedFiles = (
}
}}
>
{<SelectionIcon size={10} />}
{ // 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 ?
<span className={`text-[8px] ml-0.5 'void-opacity-60 text-void-fg-4`}>
<span className={`text-[8px] 'void-opacity-60 text-void-fg-4`}>
{`(Current File)`}
</span>
: null
@ -972,6 +981,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr
setSelections={setStagingSelections}
>
<VoidInputBox2
enableAtToMention
ref={setTextAreaRef}
className='min-h-[81px] max-h-[500px] px-0.5'
placeholder="Edit your message..."
@ -2857,8 +2867,9 @@ export const SidebarChat = () => {
onClickAnywhere={() => { textAreaRef.current?.focus() }}
>
<VoidInputBox2
enableAtToMention
className={`min-h-[81px] px-0.5 py-0.5`}
placeholder={`${keybindingString ? `${keybindingString} to add a file. ` : ''}Enter instructions...`}
placeholder={`@ to mention, ${keybindingString ? `${keybindingString} to add a file. ` : ''}Enter instructions...`}
onChangeText={onChangeText}
onKeyDown={onKeyDown}
onFocus={() => { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }}

View file

@ -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 = <CtorParams extends any[], Instance>({ ctor, prop
return <div ref={containerRef} className={className === undefined ? `w-full` : className}>{children}</div>
}
type GenerateNextOptions = (newPathText: string) => Option[]
type GenerateNextOptions = (optionText: string) => Promise<Option[]>
type Option = {
name: string,
displayName: string,
nameInMenu: string,
iconInMenu: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>, // 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<typeof useAccessor>, 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<typeof useAccessor>, 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<typeof useAccessor>, path: string[], optionText: string): Promise<Option[]> => {
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<string, URI>();
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<typeof useAccessor>, 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<typeof useAccessor>, 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<HTMLTextAreaElement>) => void;
onChangeHeight?: (newHeight: number) => void;
}
export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) {
export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(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<HTMLTextAreaElement | null>(null)
const selectedOptionRef = useRef<HTMLDivElement>(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<string[]>([]);
// logic for @ to mention vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
const [optionPath, setOptionPath] = useState<string[]>([]);
const [optionIdx, setOptionIdx] = useState<number>(0);
const [options, setOptions] = useState<Option[]>([]);
const [newPathText, setNewPathText] = useState<string>('');
const [optionText, setOptionText] = useState<string>('');
const insertTextAtCursor = (text: string) => {
const textarea = textAreaRef.current;
if (!textarea) return;
@ -184,68 +374,142 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(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<number | null>(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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement, InputBox2Props>(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<HTMLTextAreaElement, InputBox2Props>(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<HTMLTextAreaElement, InputBox2Props>(fun
inline: 'nearest',
});
}
}, [optionIdx, isMenuOpen, newPathText, selectedOptionRef]);
}, [optionIdx, isMenuOpen, optionText, selectedOptionRef]);
const measureRef = useRef<HTMLDivElement>(null);
const gapPx = 2
@ -307,7 +570,7 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
} = useFloating({
open: isMenuOpen,
onOpenChange: setIsMenuOpen,
placement: 'top',
placement: 'bottom',
middleware: [
offset({ mainAxis: gapPx, crossAxis: offsetPx }),
@ -320,13 +583,9 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(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<HTMLTextAreaElement, InputBox2Props>(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<HTMLTextAreaElement, InputBox2Props>(fun
rows={1}
placeholder={placeholder}
/>
<div>{`idx ${optionIdx}`}</div>
{isMenuOpen && (
<div
ref={refs.setFloating}
className="z-[100] bg-void-bg-1 border-void-border-3 border rounded shadow-lg"
className="z-[100] border-void-border-3 bg-void-bg-2-alt border rounded shadow-lg flex flex-col overflow-hidden"
style={{
position: strategy,
top: y ?? 0,
@ -480,41 +738,53 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
}}
onWheel={(e) => e.stopPropagation()}
>
<div className="py-1">
{/* Path navigation breadcrumbs */}
<div className="px-2 py-1 text-void-fg-3 text-sm border-b border-void-border-3">
{[...path, newPathText].join(' > ')}
{/* Breadcrumbs Header */}
<div className="px-2 py-1 text-void-fg-3 bg-void-bg-2-alt text-sm border-b border-void-border-3 sticky top-0 bg-void-bg-1 z-10 select-none pointer-events-none">
{optionPath.length || optionText ?
<div className="flex items-center">
{optionPath.map((path, index) => (
<React.Fragment key={index}>
<span>{path}</span>
<ChevronRight size={12} className="mx-1" />
</React.Fragment>
))}
<span>{optionText}</span>
</div>
: <div className='opacity-60'>Enter text to filter...</div>
}
</div>
{/* Options list */}
<div className='max-h-[400px] w-full max-w-full overflow-y-auto overflow-x-auto'>
<div className="w-max min-w-full flex flex-col gap-0 text-nowrap flex-nowrap text-sm opacity-70">
{options.length === 0 ?
<div className="text-void-fg-3 px-3 py-0.5">No results found</div>
: options.map((o, oIdx) => {
return (
// Option
<div
ref={oIdx === optionIdx ? selectedOptionRef : null}
key={o.nameInMenu}
className={`
flex items-center gap-2
px-3 py-0.5 cursor-pointer bg-void-bg-2-alt
${oIdx === optionIdx ? 'bg-void-bg-2-hover' : ''}
`}
onClick={() => { onSelectOption(); }}
onMouseOver={() => { setOptionIdx(oIdx) }}
>
{<o.iconInMenu size={12} />}
<span className="text-void-fg-1">{o.nameInMenu}</span>
{o.nextOptions || o.generateNextOptions ? (
<ChevronRight size={12} />
) : null}
</div>
)
})
}
</div>
{/* Options list */}
{options.length === 0 ? (
<div className="px-3 py-2 text-void-fg-3">No options available</div>
) : (
options.map((o, oIdx) => (
<div
ref={oIdx === optionIdx ? selectedOptionRef : null}
key={o.name}
className={`px-3 py-1.5 cursor-pointer bg-void-bg-2 ${oIdx === optionIdx ? 'bg-void-bg-2-hover' : ''}`}
onClick={() => { onSelectOption(); }}
>
<div className="flex items-center">
<span className="text-void-fg-1">{o.displayName}</span>
{o.nextOptions || o.generateNextOptions ? (
<svg className="ml-2 h-3 w-3 text-void-fg-3" viewBox="0 0 12 12" fill="none">
<path
d="M4.5 2.5L8 6L4.5 9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</div>
</div>
))
)}
</div>
</div>
)}

View file

@ -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),

View file

@ -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<ProviderName>('ollama');
const [selectedAffordableProvider, setSelectedAffordableProvider] = useState<ProviderName>('gemini');
const [selectedAllProvider, setSelectedAllProvider] = useState<ProviderName>('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) }}
/>
<PrimaryActionButton
onClick={() => { voidSettingsService.setGlobalSetting('isOnboardingComplete', true); }}
onClick={() => {
voidSettingsService.setGlobalSetting('isOnboardingComplete', true);
voidMetricsService.capture('Completed Onboarding', { selectedProviderName, wantToUseOption })
}}
ringSize={voidSettingsState.globalSettings.isOnboardingComplete ? 'screen' : undefined}
>Enter the Void</PrimaryActionButton>
</div>
@ -618,15 +623,16 @@ const VoidOnboardingContent = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full max-w-[800px] mx-auto mt-8">
<button
onClick={() => { setWantToUseOption('smart'); setPageIndex(pageIndex + 1); }}
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">
<Brain size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Intelligent</div>
<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['smart']}</div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['cheap']}</div>
</button>
<button
@ -641,14 +647,14 @@ const VoidOnboardingContent = () => {
</button>
<button
onClick={() => { setWantToUseOption('cheap'); setPageIndex(pageIndex + 1); }}
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">
<DollarSign size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Affordable</div>
<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['cheap']}</div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['smart']}</div>
</button>
</div>

View file

@ -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)
}
});

View file

@ -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)