Skip to content

Commit 8cabd7d

Browse files
feat: Add interactive file selection dropdown with @ symbol (#10)
Implements interactive file selection dropdown when typing @ symbol ## Changes - Add scanProjectFiles() function to recursively scan project files - Add selectFileInteractively() with promptui dropdown interface - Add handleFileReference() to detect @ patterns and trigger selection - Exclude binary files, build dirs, and large files from selection - Update help text and documentation for new feature - Maintain backward compatibility with existing @filename syntax ## Usage 1. Start chat: `infer chat` 2. Type `@` alone and press enter 3. Browse/search files in the dropdown 4. Select a file to include its contents Closes #3 Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: Eden Reich <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Eden Reich <[email protected]>
1 parent 2d26b9f commit 8cabd7d

File tree

5 files changed

+654
-5
lines changed

5 files changed

+654
-5
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,19 @@ Start an interactive chat session with model selection and tool support. Provide
161161

162162
**Features:**
163163
- Model selection with search
164+
- Interactive file selection with `@` symbol
164165
- File references using `@filename` syntax
165166
- Tool execution (when enabled)
166167
- Conversation history management
167168
- Real-time streaming responses
168169
- Conversation export to markdown files
169170

171+
**File Reference Options:**
172+
- Type `@` alone to open an interactive file selector dropdown
173+
- Use `@filename` to directly reference a specific file
174+
- Search and filter files in the interactive dropdown
175+
- Automatic exclusion of binary files and common build directories
176+
170177
**Examples:**
171178
```bash
172179
infer chat

cmd/chat.go

Lines changed: 182 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io/fs"
78
"os"
89
"path/filepath"
910
"regexp"
11+
"sort"
1012
"strings"
1113
"time"
1214

@@ -76,17 +78,17 @@ func startChatSession() error {
7678

7779
var conversation []sdk.Message
7880

79-
inputModel := internal.NewChatInputModel()
81+
inputModel := internal.NewChatManagerModel()
8082
program := tea.NewProgram(inputModel, tea.WithAltScreen())
8183

8284
var toolsManager *internal.LLMToolsManager
8385
if cfg.Tools.Enabled {
84-
toolsManager = internal.NewLLMToolsManagerWithUI(cfg, program, inputModel)
86+
toolsManager = internal.NewLLMToolsManagerWithUI(cfg, program, inputModel.GetChatInput())
8587
}
8688

8789
welcomeHistory := []string{
8890
fmt.Sprintf("🤖 Chat session started with %s", selectedModel),
89-
"💡 Type '/help' or '?' for commands • Use @filename for file references",
91+
"💡 Type '/help' or '?' for commands • Press @ to select files to reference",
9092
}
9193

9294
if cfg.Tools.Enabled {
@@ -119,7 +121,7 @@ func startChatSession() error {
119121
for {
120122
updateHistory(conversation)
121123

122-
userInput := waitForInput(program, inputModel)
124+
userInput := waitForInput(program, inputModel.GetChatInput())
123125
if userInput == "" {
124126
program.Quit()
125127
fmt.Println("\n👋 Chat session ended!")
@@ -135,6 +137,13 @@ func startChatSession() error {
135137
continue
136138
}
137139

140+
// Handle interactive file reference if user typed "@"
141+
userInput, err = handleFileReference(userInput)
142+
if err != nil {
143+
fmt.Printf("❌ Error with file selection: %v\n", err)
144+
continue
145+
}
146+
138147
processedInput, err := processFileReferences(userInput)
139148
if err != nil {
140149
program.Send(internal.SetStatusMsg{Message: fmt.Sprintf("❌ Error processing file references: %v", err), Spinner: false})
@@ -164,7 +173,7 @@ func startChatSession() error {
164173
break
165174
}
166175

167-
_, assistantToolCalls, metrics, err := sendStreamingChatCompletionToUI(cfg, selectedModel, conversation, program, &conversation, inputModel)
176+
_, assistantToolCalls, metrics, err := sendStreamingChatCompletionToUI(cfg, selectedModel, conversation, program, &conversation, inputModel.GetChatInput())
168177

169178
if err != nil {
170179
if strings.Contains(err.Error(), "cancelled by user") {
@@ -739,6 +748,174 @@ func handleStreamErrorToUI(event sdk.SSEvent, result *uiStreamingResult) error {
739748
return fmt.Errorf("stream error: %s", errResp.Error)
740749
}
741750

751+
// scanProjectFiles recursively scans the current directory for files,
752+
// excluding common directories that should not be included
753+
func scanProjectFiles() ([]string, error) {
754+
var files []string
755+
756+
// Directories to exclude from scanning
757+
excludeDirs := map[string]bool{
758+
".git": true,
759+
".github": true,
760+
"node_modules": true,
761+
".infer": true,
762+
"vendor": true,
763+
".flox": true,
764+
"dist": true,
765+
"build": true,
766+
"bin": true,
767+
".vscode": true,
768+
".idea": true,
769+
}
770+
771+
// File extensions to exclude
772+
excludeExts := map[string]bool{
773+
".exe": true,
774+
".bin": true,
775+
".dll": true,
776+
".so": true,
777+
".dylib": true,
778+
".a": true,
779+
".o": true,
780+
".obj": true,
781+
".pyc": true,
782+
".class": true,
783+
".jar": true,
784+
".war": true,
785+
".zip": true,
786+
".tar": true,
787+
".gz": true,
788+
".rar": true,
789+
".7z": true,
790+
".png": true,
791+
".jpg": true,
792+
".jpeg": true,
793+
".gif": true,
794+
".bmp": true,
795+
".ico": true,
796+
".svg": true,
797+
".pdf": true,
798+
".mov": true,
799+
".mp4": true,
800+
".avi": true,
801+
".mp3": true,
802+
".wav": true,
803+
}
804+
805+
cwd, err := os.Getwd()
806+
if err != nil {
807+
return nil, fmt.Errorf("failed to get current directory: %w", err)
808+
}
809+
810+
err = filepath.WalkDir(cwd, func(path string, d fs.DirEntry, err error) error {
811+
if err != nil {
812+
return nil // Skip files with errors
813+
}
814+
815+
// Get relative path from current directory
816+
relPath, err := filepath.Rel(cwd, path)
817+
if err != nil {
818+
return nil // Skip if we can't get relative path
819+
}
820+
821+
// Skip directories that should be excluded
822+
if d.IsDir() {
823+
if excludeDirs[d.Name()] || strings.HasPrefix(d.Name(), ".") && d.Name() != "." {
824+
return filepath.SkipDir
825+
}
826+
return nil
827+
}
828+
829+
// Skip files with excluded extensions
830+
ext := strings.ToLower(filepath.Ext(relPath))
831+
if excludeExts[ext] {
832+
return nil
833+
}
834+
835+
// Skip very large files (over 1MB)
836+
if info, err := d.Info(); err == nil && info.Size() > 1024*1024 {
837+
return nil
838+
}
839+
840+
// Only include regular files
841+
if d.Type().IsRegular() {
842+
files = append(files, relPath)
843+
}
844+
845+
return nil
846+
})
847+
848+
if err != nil {
849+
return nil, fmt.Errorf("failed to scan directory: %w", err)
850+
}
851+
852+
// Sort files for consistent ordering
853+
sort.Strings(files)
854+
855+
return files, nil
856+
}
857+
858+
// selectFileInteractively shows a dropdown to select a file from the project
859+
func selectFileInteractively() (string, error) {
860+
files, err := scanProjectFiles()
861+
if err != nil {
862+
return "", fmt.Errorf("failed to scan project files: %w", err)
863+
}
864+
865+
if len(files) == 0 {
866+
return "", fmt.Errorf("no files found in the current directory")
867+
}
868+
869+
// Add a limit to prevent overwhelming dropdown
870+
maxFiles := 200
871+
if len(files) > maxFiles {
872+
files = files[:maxFiles]
873+
fmt.Printf("⚠️ Showing first %d files (found %d total)\n", maxFiles, len(files))
874+
}
875+
876+
fileSelector := internal.NewFileSelectorModel(files)
877+
program := tea.NewProgram(fileSelector)
878+
879+
_, err = program.Run()
880+
if err != nil {
881+
return "", fmt.Errorf("file selection failed: %w", err)
882+
}
883+
884+
if fileSelector.IsCancelled() {
885+
return "", fmt.Errorf("file selection cancelled")
886+
}
887+
888+
if !fileSelector.IsSelected() {
889+
return "", fmt.Errorf("no file was selected")
890+
}
891+
892+
return fileSelector.GetSelected(), nil
893+
}
894+
895+
// handleFileReference processes "@" references in user input
896+
func handleFileReference(input string) (string, error) {
897+
if strings.TrimSpace(input) == "@" {
898+
selectedFile, err := selectFileInteractively()
899+
if err != nil {
900+
return "", err
901+
}
902+
return "@" + selectedFile, nil
903+
}
904+
905+
if strings.HasSuffix(strings.TrimSpace(input), "@") {
906+
selectedFile, err := selectFileInteractively()
907+
if err != nil {
908+
return "", err
909+
}
910+
911+
trimmed := strings.TrimSpace(input)
912+
prefix := trimmed[:len(trimmed)-1]
913+
return prefix + "@" + selectedFile, nil
914+
}
915+
916+
return input, nil
917+
}
918+
742919
func compactConversation(conversation []sdk.Message, selectedModel string) error {
743920
cfg, err := config.LoadConfig("")
744921
if err != nil {

internal/chatinput.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ type ApprovalRequestMsg struct {
2424
Command string
2525
}
2626

27+
// FileSelectionRequestMsg is used to request file selection
28+
type FileSelectionRequestMsg struct{}
29+
30+
// FileSelectedMsg is used to indicate a file was selected
31+
type FileSelectedMsg struct {
32+
FilePath string
33+
}
34+
2735
// ChatInputModel represents a persistent chat input interface
2836
type ChatInputModel struct {
2937
textarea []string
@@ -96,6 +104,15 @@ func (m *ChatInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96104
m.approvalSelected = 0 // Start with first option selected
97105
return m, nil
98106

107+
case FileSelectedMsg:
108+
fileRef := "@" + msg.FilePath + " "
109+
currentLine := m.textarea[m.lineIndex]
110+
before := currentLine[:m.cursor]
111+
after := currentLine[m.cursor:]
112+
m.textarea[m.lineIndex] = before + fileRef + after
113+
m.cursor += len(fileRef)
114+
return m, nil
115+
99116
case SetStatusMsg:
100117
m.statusMessage = msg.Message
101118
if msg.Spinner {
@@ -244,6 +261,14 @@ func (m *ChatInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
244261
default:
245262
if !m.focusOnHistory && len(msg.String()) == 1 && msg.String()[0] >= 32 {
246263
char := msg.String()
264+
265+
// If user types "@", trigger file selector
266+
if char == "@" {
267+
return m, func() tea.Msg {
268+
return FileSelectionRequestMsg{}
269+
}
270+
}
271+
247272
currentLine := m.textarea[m.lineIndex]
248273
before := currentLine[:m.cursor]
249274
after := currentLine[m.cursor:]

0 commit comments

Comments
 (0)