Skip to content

Commit 5bf360d

Browse files
committed
initial search implemented to find file containing pattern
1 parent fd3c7db commit 5bf360d

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

images/menu-guide.png

-40.9 KB
Loading

internal/agentic/fixer.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,71 @@ func (f *AgenticCodeFixer) IsFixRequest(message string) FixDetectionResult {
220220
}
221221
}
222222

223+
// IsSearchRequest determines if a user message is asking to find or search for a file
224+
func (f *AgenticCodeFixer) IsSearchRequest(message string) bool {
225+
lowerMsg := strings.ToLower(strings.TrimSpace(message))
226+
227+
searchPrefixes := []string{
228+
"/search ",
229+
"/find ",
230+
"find where ",
231+
"where do i ",
232+
"where can i ",
233+
"search for ",
234+
"where is ",
235+
"find file ",
236+
"how to find ",
237+
"which file ",
238+
"locate file ",
239+
"what file ",
240+
}
241+
242+
for _, prefix := range searchPrefixes {
243+
if strings.HasPrefix(lowerMsg, prefix) {
244+
return true
245+
}
246+
}
247+
248+
if strings.Contains(lowerMsg, "find where") || strings.Contains(lowerMsg, "where is") || strings.Contains(lowerMsg, "which file") || strings.Contains(lowerMsg, "locate file") || strings.Contains(lowerMsg, "what file") {
249+
return true
250+
}
251+
252+
return false
253+
}
254+
255+
// ExtractSearchTerms uses the AI model to extract search term variations from a natural language request
256+
func (f *AgenticCodeFixer) ExtractSearchTerms(message string) ([]string, error) {
257+
prompt := fmt.Sprintf("Analyze the following user request to find a specific file or code snippet.\n\nUser Request: %q\n\nExtract a comma-separated list of search queries to try, ordered from most specific to least specific. Include base variations of words (e.g., 'tokenized' -> 'token', 'prefixes' -> 'prefix') and synonyms. Output ONLY the raw comma-separated list without quotes, explanations, or markdown. Example: tokenized search pattern, token search pattern, search pattern, token, search", message)
258+
259+
responseChan, err := f.aiClient.Generate(prompt, f.model, nil)
260+
if err != nil {
261+
return nil, err
262+
}
263+
264+
var termBuilder strings.Builder
265+
for chunk := range responseChan {
266+
termBuilder.WriteString(chunk)
267+
}
268+
269+
termString := strings.TrimSpace(termBuilder.String())
270+
termString = strings.Trim(termString, "\"'`")
271+
272+
rawTerms := strings.Split(termString, ",")
273+
var terms []string
274+
for _, t := range rawTerms {
275+
t = strings.TrimSpace(t)
276+
if t != "" {
277+
terms = append(terms, t)
278+
}
279+
}
280+
281+
if len(terms) == 0 {
282+
return nil, fmt.Errorf("no search terms generated")
283+
}
284+
285+
return terms, nil
286+
}
287+
223288
// BuildPrompt constructs a structured prompt for the AI model
224289
// It combines the user's request with file context and provides clear instructions
225290
// for generating code fixes.

internal/filemanager/filemanager.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,121 @@ func (fm *FileManager) ListFiles() ([]string, error) {
264264
return files, nil
265265
}
266266

267+
// SearchFilesContent searches for text within the workspace, trying multiple search terms in order of specificity.
268+
func (fm *FileManager) SearchFilesContent(searchTerms []string) ([]string, error) {
269+
var exactResults []string
270+
var tokenResults []string
271+
272+
var lowerTerms []string
273+
var tokenSets [][]string
274+
275+
for _, term := range searchTerms {
276+
lower := strings.ToLower(term)
277+
lowerTerms = append(lowerTerms, lower)
278+
279+
// Clean up tokens by removing common delimiters
280+
cleanTerm := lower
281+
cleanTerm = strings.ReplaceAll(cleanTerm, "_", " ")
282+
cleanTerm = strings.ReplaceAll(cleanTerm, "-", " ")
283+
cleanTerm = strings.ReplaceAll(cleanTerm, ".", " ")
284+
tokens := strings.Fields(cleanTerm)
285+
if len(tokens) > 0 {
286+
tokenSets = append(tokenSets, tokens)
287+
}
288+
}
289+
290+
// Extensions to exclude
291+
excludedExts := map[string]bool{
292+
".ico": true, ".png": true, ".jpg": true, ".jpeg": true, ".bmp": true,
293+
".gif": true, ".svg": true, ".webp": true, ".exe": true, ".dll": true,
294+
".pdf": true, ".zip": true, ".tar": true, ".gz": true, ".bin": true,
295+
".so": true, ".dylib": true, ".class": true, ".obj": true, ".o": true,
296+
}
297+
298+
err := filepath.Walk(fm.workspaceDir, func(path string, info os.FileInfo, err error) error {
299+
if err != nil {
300+
return err
301+
}
302+
303+
// Skip directories and hidden files/folders
304+
if info.IsDir() {
305+
name := filepath.Base(path)
306+
// Skip hidden dirs, node_modules, vendor etc. to speed up search
307+
if name[0] == '.' || name == "node_modules" || name == "vendor" || name == ".git" {
308+
return filepath.SkipDir
309+
}
310+
return nil
311+
}
312+
313+
// Skip hidden files
314+
if filepath.Base(path)[0] == '.' {
315+
return nil
316+
}
317+
318+
// Skip excluded file types
319+
ext := strings.ToLower(filepath.Ext(path))
320+
if excludedExts[ext] {
321+
return nil
322+
}
323+
324+
// Skip large files (> 1MB)
325+
if info.Size() > 1024*1024 {
326+
return nil
327+
}
328+
329+
content, err := os.ReadFile(path)
330+
if err != nil {
331+
return nil
332+
}
333+
334+
lowerContent := strings.ToLower(string(content))
335+
relPath, err := filepath.Rel(fm.workspaceDir, path)
336+
if err != nil {
337+
return nil
338+
}
339+
340+
// 1. Exact Match on any term
341+
matchedExact := false
342+
for _, term := range lowerTerms {
343+
if strings.Contains(lowerContent, term) {
344+
exactResults = append(exactResults, relPath)
345+
matchedExact = true
346+
break
347+
}
348+
}
349+
350+
if matchedExact {
351+
return nil
352+
}
353+
354+
// 2. Tokenized/Fuzzy match on any token set (fallback)
355+
for _, tokens := range tokenSets {
356+
allMatched := true
357+
for _, token := range tokens {
358+
if !strings.Contains(lowerContent, token) {
359+
allMatched = false
360+
break
361+
}
362+
}
363+
if allMatched {
364+
tokenResults = append(tokenResults, relPath)
365+
break
366+
}
367+
}
368+
369+
return nil
370+
})
371+
372+
if err != nil {
373+
return nil, fmt.Errorf("failed to search files: %w", err)
374+
}
375+
376+
if len(exactResults) > 0 {
377+
return exactResults, nil
378+
}
379+
return tokenResults, nil
380+
}
381+
267382
// ListDirectories returns a list of subdirectories in the given directory
268383
func (fm *FileManager) ListDirectories(dirPath string) ([]string, error) {
269384
entries, err := os.ReadDir(dirPath)

internal/ui/app.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,39 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
545545

546546
return a, nil
547547

548+
case SearchCompleteMsg:
549+
a.aiPane.streaming = false
550+
if len(msg.Results) > 0 {
551+
// Open the first matching file
552+
fullPath := filepath.Join(a.config.WorkspaceDir, msg.Results[0])
553+
err := a.editorPane.LoadFile(fullPath)
554+
if err != nil {
555+
a.statusMessage = "Error opening found file: " + err.Error()
556+
a.aiPane.DisplayNotification("Found matches for '" + msg.SearchTerm + "', but failed to open " + msg.Results[0])
557+
} else {
558+
a.statusMessage = "Opened: " + msg.Results[0]
559+
a.aiPane.SetWorkingDir(filepath.Dir(a.editorPane.currentFile.Filepath))
560+
561+
// Switch to editor pane
562+
a.activePane = types.EditorPaneType
563+
a.editorPane.focused = true
564+
a.aiPane.focused = false
565+
566+
var chatMsg strings.Builder
567+
chatMsg.WriteString(fmt.Sprintf("🔍 Found '%s' in %d file(s):\n", msg.SearchTerm, len(msg.Results)))
568+
for i, res := range msg.Results {
569+
if i >= 5 {
570+
chatMsg.WriteString(fmt.Sprintf("- ... and %d more\n", len(msg.Results)-5))
571+
break
572+
}
573+
chatMsg.WriteString("- " + res + "\n")
574+
}
575+
chatMsg.WriteString("\nOpening the first match: " + msg.Results[0])
576+
a.aiPane.DisplayNotification(chatMsg.String())
577+
}
578+
}
579+
return a, nil
580+
548581
case TerminalOutputMsg, TerminalDoneMsg, AIResponseMsg, AINotificationMsg, AIAvailabilityMsg, ClearStatusMsg:
549582
// Handle ClearStatusMsg
550583
if _, ok := msg.(ClearStatusMsg); ok {
@@ -2138,6 +2171,53 @@ func (a *App) handleAIMessage(message string) tea.Cmd {
21382171
cleanMessage = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(message), "/preview"))
21392172
}
21402173

2174+
// Check if this is a search request
2175+
isSearch := a.agenticFixer.IsSearchRequest(cleanMessage)
2176+
if isSearch {
2177+
a.aiPane.AddFixRequest(message, filePath)
2178+
a.aiPane.streaming = true
2179+
2180+
return func() tea.Msg {
2181+
// Extract search terms using AI
2182+
terms, err := a.agenticFixer.ExtractSearchTerms(cleanMessage)
2183+
if err != nil || len(terms) == 0 {
2184+
return AgenticFixResultMsg{
2185+
Result: &agentic.FixResult{
2186+
Success: false,
2187+
ErrorMessage: "Could not extract search terms.",
2188+
IsConversational: false,
2189+
},
2190+
}
2191+
}
2192+
2193+
results, err := a.fileManager.SearchFilesContent(terms)
2194+
if err != nil {
2195+
return AgenticFixResultMsg{
2196+
Result: &agentic.FixResult{
2197+
Success: false,
2198+
ErrorMessage: "Search failed: " + err.Error(),
2199+
IsConversational: false,
2200+
},
2201+
}
2202+
}
2203+
2204+
if len(results) == 0 {
2205+
return AgenticFixResultMsg{
2206+
Result: &agentic.FixResult{
2207+
Success: false,
2208+
ErrorMessage: "No files found containing variations of: " + terms[0],
2209+
IsConversational: false,
2210+
},
2211+
}
2212+
}
2213+
2214+
return SearchCompleteMsg{
2215+
SearchTerm: strings.Join(terms, ", "),
2216+
Results: results,
2217+
}
2218+
}
2219+
}
2220+
21412221
isFixDetection := a.agenticFixer.IsFixRequest(cleanMessage)
21422222

21432223
// Step 3: Handle conversational mode immediately

internal/ui/messages.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ type WindowSizeMsg = tea.WindowSizeMsg
1212
type AgenticFixResultMsg struct {
1313
Result *agentic.FixResult
1414
}
15+
16+
// SearchCompleteMsg is sent when a workspace search completes.
17+
type SearchCompleteMsg struct {
18+
SearchTerm string
19+
Results []string
20+
}

0 commit comments

Comments
 (0)