filesearch working

This commit is contained in:
dal 2025-09-30 13:14:16 -06:00
parent 46a98a1a02
commit 3821f29caf
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
6 changed files with 583 additions and 27 deletions

View File

@ -8,7 +8,7 @@
},
"scripts": {
"prebuild": "[ \"$SKIP_ENV_CHECK\" = \"true\" ] || tsx scripts/validate-env.ts",
"build": "bun build src/index.tsx --outdir dist --target node",
"build": "bun build src/index.tsx --outdir dist --target bun",
"build:dry-run": "tsc --noEmit",
"build:standalone": "bun build src/index.tsx --compile --outfile dist/buster",
"start": "bun run dist/index.js",

View File

@ -1,6 +1,8 @@
import { Box, Text } from 'ink';
import { useMemo } from 'react';
import { MultiLineTextInput } from './multi-line-text-input';
import { useEffect, useMemo, useState } from 'react';
import { type FileSearchResult, searchFiles } from '../utils/file-search';
import { FileAutocompleteDisplay } from './file-autocomplete-display';
import { MultiLineTextInput, replaceMention } from './multi-line-text-input';
import { SimpleBigText } from './simple-big-text';
export function ChatTitle() {
@ -66,26 +68,112 @@ interface ChatInputProps {
}
export function ChatInput({ value, placeholder, onChange, onSubmit }: ChatInputProps) {
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionStart, setMentionStart] = useState<number>(-1);
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
// Handle mention changes from the input
const handleMentionChange = (query: string | null, position: number) => {
// Only reset selection if the query actually changed
if (query !== mentionQuery) {
setSelectedIndex(0);
}
setMentionQuery(query);
setMentionStart(position);
setShowAutocomplete(query !== null);
};
// Search for files when mention query changes
useEffect(() => {
if (mentionQuery !== null) {
searchFiles(mentionQuery, { maxResults: 20 })
.then((results) => {
setSearchResults(results);
// Adjust selection if it's out of bounds
setSelectedIndex((currentIndex) => {
if (currentIndex >= results.length && results.length > 0) {
return results.length - 1;
}
return currentIndex;
});
})
.catch((error) => {
console.error('File search failed:', error);
setSearchResults([]);
});
} else {
setSearchResults([]);
}
}, [mentionQuery]);
// Handle autocomplete navigation
const handleAutocompleteNavigate = (direction: 'up' | 'down' | 'select' | 'close') => {
const displayItems = searchResults.slice(0, 10);
switch (direction) {
case 'up':
setSelectedIndex((prev) => Math.max(0, prev - 1));
break;
case 'down':
setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1));
break;
case 'select':
if (displayItems[selectedIndex]) {
const file = displayItems[selectedIndex];
if (mentionStart !== -1 && mentionQuery !== null) {
const mentionEnd = mentionStart + mentionQuery.length + 1; // +1 for the @ symbol
const replacement = `@${file.relativePath} `; // Always add space
const newValue = replaceMention(value, mentionStart, mentionEnd, replacement);
onChange(newValue);
setShowAutocomplete(false);
setMentionQuery(null);
setMentionStart(-1);
}
}
break;
case 'close':
setShowAutocomplete(false);
setMentionQuery(null);
setMentionStart(-1);
break;
}
};
return (
<Box
borderStyle="single"
borderColor="#4c1d95"
paddingX={1}
width="100%"
marginTop={1}
flexDirection="row"
>
<Text color="#a855f7" bold>
{' '}
</Text>
<Box flexGrow={1}>
<MultiLineTextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder}
/>
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor="#4c1d95"
paddingX={1}
width="100%"
flexDirection="row"
>
<Text color="#a855f7" bold>
{' '}
</Text>
<Box flexGrow={1}>
<MultiLineTextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
onMentionChange={handleMentionChange}
onAutocompleteNavigate={handleAutocompleteNavigate}
placeholder={placeholder}
isAutocompleteOpen={showAutocomplete}
/>
</Box>
</Box>
{showAutocomplete && (
<Box marginTop={0} paddingLeft={2}>
<FileAutocompleteDisplay
items={searchResults}
selectedIndex={selectedIndex}
maxDisplay={10}
/>
</Box>
)}
</Box>
);
}

View File

@ -0,0 +1,38 @@
import { Box, Text } from 'ink';
import type { FileSearchResult } from '../utils/file-search';
interface FileAutocompleteDisplayProps {
items: FileSearchResult[];
selectedIndex: number;
maxDisplay?: number;
}
export function FileAutocompleteDisplay({
items,
selectedIndex,
maxDisplay = 10,
}: FileAutocompleteDisplayProps) {
const displayItems = items.slice(0, maxDisplay);
if (items.length === 0) {
return null; // Don't show anything when no matches
}
return (
<Box flexDirection="column">
{displayItems.map((item, index) => {
const isSelected = index === selectedIndex;
const { relativePath } = item;
return (
<Box key={relativePath}>
<Text color={isSelected ? '#ffffff' : '#6b7280'}>
{isSelected ? '> ' : ' '}
{relativePath}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@ -0,0 +1,85 @@
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import type { FileSearchResult } from '../utils/file-search';
interface FileAutocompleteProps {
items: FileSearchResult[];
onSelect: (item: FileSearchResult, addSpace: boolean) => void;
onClose: () => void;
isActive?: boolean;
maxDisplay?: number;
}
export function FileAutocomplete({
items,
onSelect,
onClose,
isActive = false, // Changed default to false - parent controls this
maxDisplay = 10,
}: FileAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const displayItems = items.slice(0, maxDisplay);
// Reset selection when items change
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useInput(
(_input, key) => {
if (key.escape) {
onClose();
return;
}
if (key.return) {
if (displayItems[selectedIndex]) {
// Enter adds the file with a space
onSelect(displayItems[selectedIndex], true);
}
return;
}
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow) {
setSelectedIndex((prev) => Math.min(displayItems.length - 1, prev + 1));
return;
}
if (key.tab) {
// Tab also selects the current item with a space
if (displayItems[selectedIndex]) {
onSelect(displayItems[selectedIndex], true);
}
return;
}
},
{ isActive }
);
if (items.length === 0) {
return null; // Don't show anything when no matches
}
return (
<Box flexDirection="column">
{displayItems.map((item, index) => {
const isSelected = index === selectedIndex;
const { relativePath } = item;
return (
<Box key={relativePath}>
<Text color={isSelected ? '#ffffff' : '#6b7280'}>
{isSelected ? '> ' : ' '}
{relativePath}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@ -5,16 +5,22 @@ interface MultiLineTextInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onMentionChange?: (mention: string | null, cursorPosition: number) => void;
onAutocompleteNavigate?: (direction: 'up' | 'down' | 'select' | 'close') => void;
placeholder?: string;
focus?: boolean;
isAutocompleteOpen?: boolean;
}
export function MultiLineTextInput({
value,
onChange,
onSubmit,
onMentionChange,
onAutocompleteNavigate,
placeholder = '',
focus = true,
isAutocompleteOpen = false,
}: MultiLineTextInputProps) {
const [cursorPosition, setCursorPosition] = useState(value.length);
const [showCursor, setShowCursor] = useState(true);
@ -43,6 +49,43 @@ export function MultiLineTextInput({
setCursorPosition(value.length);
}, [value]);
// Detect @ mentions
useEffect(() => {
if (!onMentionChange) return;
// Find the last @ before cursor position
let mentionStart = -1;
for (let i = cursorPosition - 1; i >= 0; i--) {
if (value[i] === '@') {
mentionStart = i;
break;
}
// Stop if we hit whitespace or newline (mention ended)
if (value[i] === ' ' || value[i] === '\n' || value[i] === '\t') {
break;
}
}
if (mentionStart !== -1) {
// Check if there's a space or newline before @ (or it's at the start)
const charBefore = mentionStart > 0 ? value[mentionStart - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t' || mentionStart === 0) {
// Extract the mention query (text after @)
const mentionEnd = cursorPosition;
const mentionQuery = value.substring(mentionStart + 1, mentionEnd);
// Only trigger if we're still in the mention (no spaces)
if (!mentionQuery.includes(' ') && !mentionQuery.includes('\n')) {
onMentionChange(mentionQuery, mentionStart);
return;
}
}
}
// No active mention
onMentionChange(null, -1);
}, [value, cursorPosition, onMentionChange]);
useInput(
(input, key) => {
if (!focus) return;
@ -50,6 +93,26 @@ export function MultiLineTextInput({
// Debug: Log what we're receiving
// console.log('Input:', input, 'Key:', key);
// Handle autocomplete navigation when it's open
if (isAutocompleteOpen && onAutocompleteNavigate) {
if (key.upArrow) {
onAutocompleteNavigate('up');
return;
}
if (key.downArrow) {
onAutocompleteNavigate('down');
return;
}
if (key.escape) {
onAutocompleteNavigate('close');
return;
}
if (key.return || key.tab) {
onAutocompleteNavigate('select');
return;
}
}
// Handle backslash + n sequence for newline
if (expectingNewline) {
setExpectingNewline(false);
@ -120,8 +183,9 @@ export function MultiLineTextInput({
return;
}
// Handle Enter - submit (only if not modified)
if (key.return && !key.meta && !key.shift && !key.ctrl) {
// Handle Enter - submit (only if not modified and autocomplete is not open)
// This is now handled above in autocomplete navigation section
if (key.return && !key.meta && !key.shift && !key.ctrl && !isAutocompleteOpen) {
onSubmit();
return;
}
@ -145,8 +209,8 @@ export function MultiLineTextInput({
return;
}
if (key.upArrow) {
// Move cursor to previous line
if (key.upArrow && !isAutocompleteOpen) {
// Move cursor to previous line (only when autocomplete is closed)
const lines = value.split('\n');
let currentLineStart = 0;
let currentLineIndex = 0;
@ -173,8 +237,8 @@ export function MultiLineTextInput({
return;
}
if (key.downArrow) {
// Move cursor to next line
if (key.downArrow && !isAutocompleteOpen) {
// Move cursor to next line (only when autocomplete is closed)
const lines = value.split('\n');
let currentLineStart = 0;
let currentLineIndex = 0;
@ -303,3 +367,13 @@ export function MultiLineTextInput({
return renderTextWithCursor();
}
// Helper function to replace a mention with a file path
export function replaceMention(
text: string,
mentionStart: number,
mentionEnd: number,
replacement: string
): string {
return text.slice(0, mentionStart) + replacement + text.slice(mentionEnd);
}

View File

@ -0,0 +1,271 @@
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { Glob } from 'bun';
import micromatch from 'micromatch';
interface FileSearchOptions {
cwd?: string;
maxResults?: number;
includeHidden?: boolean;
}
interface FileSearchResult {
path: string;
relativePath: string;
name: string;
}
class FileSearcher {
private cwd: string;
private fileCache: string[] | null = null;
private gitignorePatterns: string[] | null = null;
private lastCacheTime: number = 0;
private readonly CACHE_TTL = 5000; // 5 seconds cache
constructor(cwd: string = process.cwd()) {
this.cwd = resolve(cwd);
}
private parseGitignore(content: string): string[] {
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((pattern) => {
// Convert gitignore patterns to glob patterns
if (pattern.startsWith('!')) {
return pattern; // Keep negation patterns as-is
}
if (pattern.startsWith('/')) {
return pattern.slice(1); // Remove leading slash for relative patterns
}
if (!pattern.includes('/')) {
// Match file/folder anywhere in tree
return `**/${pattern}`;
}
return pattern;
});
}
private loadGitignorePatterns(): string[] {
if (this.gitignorePatterns !== null) {
return this.gitignorePatterns;
}
const patterns: string[] = [
// Default exclusions
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/.turbo/**',
'**/.next/**',
'**/coverage/**',
'**/.cache/**',
];
// Load .gitignore if it exists
const gitignorePath = join(this.cwd, '.gitignore');
if (existsSync(gitignorePath)) {
try {
const content = readFileSync(gitignorePath, 'utf-8');
const gitignorePatterns = this.parseGitignore(content);
patterns.push(...gitignorePatterns);
} catch (error) {
console.error('Failed to read .gitignore:', error);
}
}
// Load .gitignore from parent directories up to root
let currentDir = resolve(this.cwd, '..');
const root = resolve('/');
while (currentDir !== root) {
const parentGitignorePath = join(currentDir, '.gitignore');
if (existsSync(parentGitignorePath)) {
try {
const content = readFileSync(parentGitignorePath, 'utf-8');
const gitignorePatterns = this.parseGitignore(content);
// Adjust patterns relative to our cwd
const adjustedPatterns = gitignorePatterns.map((pattern) => {
if (pattern.startsWith('!') || pattern.startsWith('**/')) {
return pattern;
}
return `**/${pattern}`;
});
patterns.push(...adjustedPatterns);
} catch (error) {
// Silently ignore
}
}
const parentDir = resolve(currentDir, '..');
if (parentDir === currentDir) break; // Reached root
currentDir = parentDir;
}
this.gitignorePatterns = patterns;
return patterns;
}
private async getAllFiles(includeHidden: boolean = false): Promise<string[]> {
const now = Date.now();
// Return cached files if still valid
if (this.fileCache && now - this.lastCacheTime < this.CACHE_TTL) {
return this.fileCache;
}
const ignorePatterns = this.loadGitignorePatterns();
// Use Bun's Glob to find all files
const glob = new Glob('**/*', {
cwd: this.cwd,
onlyFiles: true,
dot: includeHidden, // Include hidden files if requested
});
const allFiles: string[] = [];
for await (const file of glob.scan()) {
// Check if file should be ignored
const shouldIgnore = micromatch.isMatch(file, ignorePatterns, {
dot: true,
contains: true,
});
if (!shouldIgnore) {
allFiles.push(file);
}
}
// Update cache
this.fileCache = allFiles.sort();
this.lastCacheTime = now;
return this.fileCache;
}
async searchFiles(query: string, options: FileSearchOptions = {}): Promise<FileSearchResult[]> {
const { cwd = this.cwd, maxResults = 10, includeHidden = false } = options;
// Update cwd if different
if (cwd !== this.cwd) {
this.cwd = resolve(cwd);
this.fileCache = null; // Clear cache when directory changes
this.gitignorePatterns = null;
}
const files = await this.getAllFiles(includeHidden);
if (!query) {
// Return first N files if no query
return files.slice(0, maxResults).map((file) => ({
path: join(this.cwd, file),
relativePath: file,
name: file.split('/').pop() || file,
}));
}
// Fuzzy search - split query into parts and check if all parts are in the path
const queryParts = query.toLowerCase().split('').filter(Boolean);
const scoredFiles = files
.map((file) => {
const lowerFile = file.toLowerCase();
const fileName = file.split('/').pop() || '';
const lowerFileName = fileName.toLowerCase();
// Check if all query parts appear in order in the file path
let score = 0;
let lastIndex = -1;
let allPartsFound = true;
for (const part of queryParts) {
const indexInPath = lowerFile.indexOf(part, lastIndex + 1);
const indexInName = lowerFileName.indexOf(part, lastIndex + 1);
if (indexInPath === -1) {
allPartsFound = false;
break;
}
// Prefer matches in filename over path
if (indexInName !== -1) {
score += 2;
lastIndex = indexInName;
} else {
score += 1;
lastIndex = indexInPath;
}
// Bonus for consecutive matches
if (lastIndex === indexInPath - 1) {
score += 0.5;
}
}
if (!allPartsFound) {
return null;
}
// Bonus for exact substring match
if (lowerFile.includes(query.toLowerCase())) {
score += 5;
}
// Bonus for matching at start of filename
if (lowerFileName.startsWith(query.toLowerCase())) {
score += 10;
}
// Penalty for depth
const depth = file.split('/').length;
score -= depth * 0.1;
return {
file,
score,
};
})
.filter((item): item is { file: string; score: number } => item !== null)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults);
return scoredFiles.map(({ file }) => ({
path: join(this.cwd, file),
relativePath: file,
name: file.split('/').pop() || file,
}));
}
clearCache(): void {
this.fileCache = null;
this.gitignorePatterns = null;
this.lastCacheTime = 0;
}
}
// Export singleton instance
let searcherInstance: FileSearcher | null = null;
export function getFileSearcher(cwd?: string): FileSearcher {
if (!searcherInstance || (cwd && resolve(cwd) !== searcherInstance.cwd)) {
searcherInstance = new FileSearcher(cwd);
}
return searcherInstance;
}
export async function searchFiles(
query: string,
options?: FileSearchOptions
): Promise<FileSearchResult[]> {
const searcher = getFileSearcher(options?.cwd);
return searcher.searchFiles(query, options);
}
export function clearFileSearchCache(): void {
if (searcherInstance) {
searcherInstance.clearCache();
}
}
export type { FileSearchResult, FileSearchOptions };