-
Notifications
You must be signed in to change notification settings - Fork 444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: @ syntax for bringing files into chat context #475
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,9 +5,11 @@ import * as path from 'path' | |
import chokidar from 'chokidar' | ||
import { BrowserWindow } from 'electron' | ||
|
||
import Store from 'electron-store' | ||
import { FileInfo, FileInfoTree } from './types' | ||
import addExtensionToFilenameIfNoExtensionPresent from '../path/path' | ||
import { isFileNodeDirectory } from '../../../shared/utils' | ||
import { StoreSchema, StoreKeys } from '../electron-store/storeConfig' | ||
|
||
export const markdownExtensions = ['.md', '.markdown', '.mdown', '.mkdn', '.mkd'] | ||
|
||
|
@@ -217,3 +219,18 @@ export function splitDirectoryPathIntoBaseAndRepo(fullPath: string) { | |
|
||
return { localModelPath, repoName } | ||
} | ||
|
||
export const searchFiles = async (store: Store<StoreSchema>, searchTerm: string): Promise<string[]> => { | ||
const vaultDirectory = store.get(StoreKeys.DirectoryFromPreviousSession) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Using DirectoryFromPreviousSession instead of current window's vault directory could cause inconsistencies |
||
if (!vaultDirectory || typeof vaultDirectory !== 'string') { | ||
throw new Error('No valid vault directory found') | ||
} | ||
Comment on lines
+202
to
+204
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Error handling could provide more context about why the vault directory is invalid |
||
|
||
const allFiles = GetFilesInfoList(vaultDirectory) | ||
|
||
const searchTermLower = searchTerm.toLowerCase() | ||
|
||
const matchingFiles = allFiles.filter((file) => file.name.toLowerCase().startsWith(searchTermLower)) | ||
Comment on lines
+206
to
+210
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Loading all files into memory before filtering could be inefficient for large vaults. Consider using a streaming/iterative approach |
||
|
||
return matchingFiles.map((file) => file.path) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ import { | |
GetFilesInfoListForListOfPaths, | ||
startWatchingDirectory, | ||
updateFileListForRenderer, | ||
searchFiles, | ||
} from './filesystem' | ||
import { FileInfoTree, WriteFileProps, RenameFileProps, FileInfoWithContent } from './types' | ||
|
||
|
@@ -193,6 +194,10 @@ const registerFileHandlers = (store: Store<StoreSchema>, _windowsManager: Window | |
} | ||
return [] | ||
}) | ||
|
||
ipcMain.handle('search-files', async (_event, searchTerm: string) => { | ||
return searchFiles(store, searchTerm) | ||
}) | ||
Comment on lines
+159
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: No error handling for searchFiles - could throw unhandled exceptions if store is invalid or searchTerm is malformed. Consider wrapping in try/catch.
Comment on lines
+159
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: searchTerm parameter should be validated/sanitized to prevent injection attacks or crashes from malicious input |
||
} | ||
|
||
export default registerFileHandlers |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
import React from 'react' | ||
|
||
import React, { useState, useRef } from 'react' | ||
import { PiPaperPlaneRight } from 'react-icons/pi' | ||
import { LoadingState } from '../../lib/llm/types' | ||
import FileAutocomplete from './FileAutocomplete' | ||
|
||
interface ChatInputProps { | ||
userTextFieldInput: string | ||
|
@@ -15,46 +15,121 @@ const ChatInput: React.FC<ChatInputProps> = ({ | |
setUserTextFieldInput, | ||
handleSubmitNewMessage, | ||
loadingState, | ||
}) => ( | ||
<div className="flex h-titlebar w-full items-center justify-center p-10"> | ||
<div className=" relative bottom-5 flex w-full max-w-3xl"> | ||
<div className="w-full rounded-lg border-2 border-solid border-neutral-700 p-3 focus-within:ring-1 focus-within:ring-[#8c8c8c]"> | ||
<div className="flex h-full pr-8"> | ||
<textarea | ||
onKeyDown={(e) => { | ||
if (userTextFieldInput && !e.shiftKey && e.key === 'Enter') { | ||
e.preventDefault() | ||
handleSubmitNewMessage() | ||
} | ||
}} | ||
onChange={(e) => setUserTextFieldInput(e.target.value)} | ||
value={userTextFieldInput} | ||
className="mr-2 max-h-[50px] w-full resize-none overflow-y-auto border-0 bg-gray-300 focus:outline-none" | ||
name="Outlined" | ||
placeholder="Follow up..." | ||
rows={1} | ||
style={{ | ||
backgroundColor: 'rgba(255, 255, 255, 0)', | ||
color: 'rgb(212 212 212)', | ||
border: 'none', | ||
}} | ||
onInput={(e) => { | ||
const target = e.target as HTMLTextAreaElement // Prevent TS inferring type error | ||
target.style.height = 'auto' | ||
target.style.height = `${Math.min(target.scrollHeight, 160)}px` | ||
}} | ||
/> | ||
<div className="absolute right-3 top-1/2 -translate-y-1/2"> | ||
<PiPaperPlaneRight | ||
color={userTextFieldInput && loadingState !== 'idle' ? 'white' : 'gray'} | ||
onClick={userTextFieldInput ? handleSubmitNewMessage : undefined} | ||
className={userTextFieldInput ? 'cursor-pointer' : ''} | ||
}) => { | ||
const [showFileAutocomplete, setShowFileAutocomplete] = useState(false) | ||
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }) | ||
const [searchTerm, setSearchTerm] = useState('') | ||
const textareaRef = useRef<HTMLTextAreaElement>(null) | ||
|
||
const getCaretCoordinates = (element: HTMLTextAreaElement) => { | ||
const { selectionStart, value } = element | ||
const textBeforeCaret = value.substring(0, selectionStart) | ||
|
||
// Create a hidden div with the same styling as textarea | ||
const mirror = document.createElement('div') | ||
mirror.style.cssText = window.getComputedStyle(element).cssText | ||
mirror.style.height = 'auto' | ||
mirror.style.position = 'absolute' | ||
mirror.style.visibility = 'hidden' | ||
mirror.style.whiteSpace = 'pre-wrap' | ||
document.body.appendChild(mirror) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: DOM element is added but not removed if component unmounts unexpectedly. Wrap in useEffect with cleanup. |
||
|
||
// Create a span for the text before caret | ||
const textNode = document.createTextNode(textBeforeCaret) | ||
const span = document.createElement('span') | ||
span.appendChild(textNode) | ||
mirror.appendChild(span) | ||
|
||
// Get coordinates | ||
const coordinates = { | ||
top: span.offsetTop + parseInt(window.getComputedStyle(element).lineHeight, 10) / 2, | ||
left: span.offsetLeft, | ||
} | ||
|
||
// Clean up | ||
document.body.removeChild(mirror) | ||
|
||
return coordinates | ||
} | ||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||
if (userTextFieldInput && !e.shiftKey && e.key === 'Enter') { | ||
e.preventDefault() | ||
handleSubmitNewMessage() | ||
} else if (e.key === '@') { | ||
const rect = e.currentTarget.getBoundingClientRect() | ||
const position = getCaretCoordinates(e.currentTarget) | ||
setAutocompletePosition({ | ||
top: rect.top + position.top, | ||
left: rect.left + position.left, | ||
}) | ||
setShowFileAutocomplete(true) | ||
// console.log('showFileAutocomplete', showFileAutocomplete) | ||
} else if (showFileAutocomplete && e.key === 'Escape') { | ||
setShowFileAutocomplete(false) | ||
} | ||
} | ||
|
||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||
const { value } = e.target | ||
setUserTextFieldInput(value) | ||
|
||
// Handle @ mentions | ||
const lastAtIndex = value.lastIndexOf('@') | ||
if (lastAtIndex !== -1 && lastAtIndex < value.length) { | ||
const searchText = value.slice(lastAtIndex + 1).split(/\s/)[0] | ||
setSearchTerm(searchText) | ||
} else { | ||
setShowFileAutocomplete(false) | ||
} | ||
Comment on lines
+89
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: lastIndexOf('@') could match an @ character in a file path. Use regex to find last @ not inside a file reference. |
||
|
||
// Adjust textarea height | ||
e.target.style.height = 'auto' | ||
e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px` | ||
} | ||
|
||
const handleFileSelect = (filePath: string) => { | ||
const lastAtIndex = userTextFieldInput.lastIndexOf('@') | ||
const newValue = `${userTextFieldInput.slice(0, lastAtIndex)}@${filePath} ${userTextFieldInput.slice( | ||
lastAtIndex + searchTerm.length + 1, | ||
)}` | ||
|
||
Comment on lines
+103
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: handleFileSelect doesn't handle case where searchTerm is undefined, could cause incorrect slicing |
||
setUserTextFieldInput(newValue) | ||
setShowFileAutocomplete(false) | ||
} | ||
|
||
return ( | ||
<div className="flex h-titlebar w-full items-center justify-center p-10"> | ||
<div className="relative bottom-5 flex w-full max-w-3xl"> | ||
<div className="w-full rounded-lg border-2 border-solid border-neutral-700 p-3 focus-within:ring-1 focus-within:ring-[#8c8c8c]"> | ||
<div className="flex h-full pr-8"> | ||
<textarea | ||
ref={textareaRef} | ||
onKeyDown={handleKeyDown} | ||
onChange={handleInput} | ||
value={userTextFieldInput} | ||
className="mr-2 max-h-[50px] w-full resize-none overflow-y-auto border-0 bg-transparent text-foreground focus:outline-none" | ||
placeholder="Type @ to reference files..." | ||
rows={1} | ||
/> | ||
<div className="absolute right-3 top-1/2 -translate-y-1/2"> | ||
<PiPaperPlaneRight | ||
color={userTextFieldInput && loadingState !== 'idle' ? 'white' : 'gray'} | ||
onClick={userTextFieldInput ? handleSubmitNewMessage : undefined} | ||
className={userTextFieldInput ? 'cursor-pointer' : ''} | ||
/> | ||
</div> | ||
</div> | ||
</div> | ||
<FileAutocomplete | ||
searchTerm={searchTerm} | ||
position={autocompletePosition} | ||
onSelect={handleFileSelect} | ||
visible={showFileAutocomplete} | ||
/> | ||
</div> | ||
</div> | ||
</div> | ||
) | ||
) | ||
} | ||
|
||
export default ChatInput |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import React, { useEffect, useState } from 'react' | ||
|
||
interface FileAutocompleteProps { | ||
searchTerm: string | ||
position: { top: number; left: number } | ||
onSelect: (filePath: string) => void | ||
visible: boolean | ||
} | ||
|
||
const FileAutocomplete: React.FC<FileAutocompleteProps> = ({ searchTerm, position, onSelect, visible }) => { | ||
const [files, setFiles] = useState<string[]>([]) | ||
|
||
useEffect(() => { | ||
const searchFiles = async () => { | ||
if (searchTerm && visible) { | ||
// Use the electron API to search for files | ||
const results = await window.fileSystem.searchFiles(searchTerm) | ||
setFiles(results) | ||
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: searchFiles() could fail but has no error handling. Add try/catch and show error state to user. |
||
} | ||
} | ||
searchFiles() | ||
}, [searchTerm, visible]) | ||
Comment on lines
+13
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: useEffect runs on every searchTerm change without debouncing, could cause performance issues with rapid typing |
||
|
||
if (!visible || !searchTerm) return null | ||
|
||
return ( | ||
<div | ||
className="absolute z-50 max-h-48 w-64 overflow-y-auto rounded-md border border-neutral-700 bg-background shadow-lg" | ||
style={{ top: position.top, left: position.left }} | ||
> | ||
{files.map((file) => ( | ||
<div key={file} className="cursor-pointer px-4 py-2 hover:bg-neutral-700" onClick={() => onSelect(file)}> | ||
{file.split('/').pop()} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: file.split('/').pop() could return undefined if path ends in / |
||
</div> | ||
))} | ||
Comment on lines
+35
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: no empty state UI when search returns no results |
||
</div> | ||
) | ||
} | ||
|
||
export default FileAutocomplete |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Function marked as async but contains no await operations