diff --git a/README.md b/README.md index b4718a51..bc9eb443 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,19 @@ Start an interactive chat session with model selection and tool support. Provide **Features:** - Model selection with search +- Interactive file selection with `@` symbol - File references using `@filename` syntax - Tool execution (when enabled) - Conversation history management - Real-time streaming responses - Conversation export to markdown files +**File Reference Options:** +- Type `@` alone to open an interactive file selector dropdown +- Use `@filename` to directly reference a specific file +- Search and filter files in the interactive dropdown +- Automatic exclusion of binary files and common build directories + **Examples:** ```bash infer chat diff --git a/cmd/chat.go b/cmd/chat.go index 61759b8e..55d397b3 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "regexp" + "sort" "strings" "time" @@ -76,17 +78,17 @@ func startChatSession() error { var conversation []sdk.Message - inputModel := internal.NewChatInputModel() + inputModel := internal.NewChatManagerModel() program := tea.NewProgram(inputModel, tea.WithAltScreen()) var toolsManager *internal.LLMToolsManager if cfg.Tools.Enabled { - toolsManager = internal.NewLLMToolsManagerWithUI(cfg, program, inputModel) + toolsManager = internal.NewLLMToolsManagerWithUI(cfg, program, inputModel.GetChatInput()) } welcomeHistory := []string{ fmt.Sprintf("šŸ¤– Chat session started with %s", selectedModel), - "šŸ’” Type '/help' or '?' for commands • Use @filename for file references", + "šŸ’” Type '/help' or '?' for commands • Press @ to select files to reference", } if cfg.Tools.Enabled { @@ -119,7 +121,7 @@ func startChatSession() error { for { updateHistory(conversation) - userInput := waitForInput(program, inputModel) + userInput := waitForInput(program, inputModel.GetChatInput()) if userInput == "" { program.Quit() fmt.Println("\nšŸ‘‹ Chat session ended!") @@ -135,6 +137,13 @@ func startChatSession() error { continue } + // Handle interactive file reference if user typed "@" + userInput, err = handleFileReference(userInput) + if err != nil { + fmt.Printf("āŒ Error with file selection: %v\n", err) + continue + } + processedInput, err := processFileReferences(userInput) if err != nil { program.Send(internal.SetStatusMsg{Message: fmt.Sprintf("āŒ Error processing file references: %v", err), Spinner: false}) @@ -164,7 +173,7 @@ func startChatSession() error { break } - _, assistantToolCalls, metrics, err := sendStreamingChatCompletionToUI(cfg, selectedModel, conversation, program, &conversation, inputModel) + _, assistantToolCalls, metrics, err := sendStreamingChatCompletionToUI(cfg, selectedModel, conversation, program, &conversation, inputModel.GetChatInput()) if err != nil { if strings.Contains(err.Error(), "cancelled by user") { @@ -739,6 +748,174 @@ func handleStreamErrorToUI(event sdk.SSEvent, result *uiStreamingResult) error { return fmt.Errorf("stream error: %s", errResp.Error) } +// scanProjectFiles recursively scans the current directory for files, +// excluding common directories that should not be included +func scanProjectFiles() ([]string, error) { + var files []string + + // Directories to exclude from scanning + excludeDirs := map[string]bool{ + ".git": true, + ".github": true, + "node_modules": true, + ".infer": true, + "vendor": true, + ".flox": true, + "dist": true, + "build": true, + "bin": true, + ".vscode": true, + ".idea": true, + } + + // File extensions to exclude + excludeExts := map[string]bool{ + ".exe": true, + ".bin": true, + ".dll": true, + ".so": true, + ".dylib": true, + ".a": true, + ".o": true, + ".obj": true, + ".pyc": true, + ".class": true, + ".jar": true, + ".war": true, + ".zip": true, + ".tar": true, + ".gz": true, + ".rar": true, + ".7z": true, + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, + ".bmp": true, + ".ico": true, + ".svg": true, + ".pdf": true, + ".mov": true, + ".mp4": true, + ".avi": true, + ".mp3": true, + ".wav": true, + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current directory: %w", err) + } + + err = filepath.WalkDir(cwd, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // Skip files with errors + } + + // Get relative path from current directory + relPath, err := filepath.Rel(cwd, path) + if err != nil { + return nil // Skip if we can't get relative path + } + + // Skip directories that should be excluded + if d.IsDir() { + if excludeDirs[d.Name()] || strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + + // Skip files with excluded extensions + ext := strings.ToLower(filepath.Ext(relPath)) + if excludeExts[ext] { + return nil + } + + // Skip very large files (over 1MB) + if info, err := d.Info(); err == nil && info.Size() > 1024*1024 { + return nil + } + + // Only include regular files + if d.Type().IsRegular() { + files = append(files, relPath) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to scan directory: %w", err) + } + + // Sort files for consistent ordering + sort.Strings(files) + + return files, nil +} + +// selectFileInteractively shows a dropdown to select a file from the project +func selectFileInteractively() (string, error) { + files, err := scanProjectFiles() + if err != nil { + return "", fmt.Errorf("failed to scan project files: %w", err) + } + + if len(files) == 0 { + return "", fmt.Errorf("no files found in the current directory") + } + + // Add a limit to prevent overwhelming dropdown + maxFiles := 200 + if len(files) > maxFiles { + files = files[:maxFiles] + fmt.Printf("āš ļø Showing first %d files (found %d total)\n", maxFiles, len(files)) + } + + fileSelector := internal.NewFileSelectorModel(files) + program := tea.NewProgram(fileSelector) + + _, err = program.Run() + if err != nil { + return "", fmt.Errorf("file selection failed: %w", err) + } + + if fileSelector.IsCancelled() { + return "", fmt.Errorf("file selection cancelled") + } + + if !fileSelector.IsSelected() { + return "", fmt.Errorf("no file was selected") + } + + return fileSelector.GetSelected(), nil +} + +// handleFileReference processes "@" references in user input +func handleFileReference(input string) (string, error) { + if strings.TrimSpace(input) == "@" { + selectedFile, err := selectFileInteractively() + if err != nil { + return "", err + } + return "@" + selectedFile, nil + } + + if strings.HasSuffix(strings.TrimSpace(input), "@") { + selectedFile, err := selectFileInteractively() + if err != nil { + return "", err + } + + trimmed := strings.TrimSpace(input) + prefix := trimmed[:len(trimmed)-1] + return prefix + "@" + selectedFile, nil + } + + return input, nil +} + func compactConversation(conversation []sdk.Message, selectedModel string) error { cfg, err := config.LoadConfig("") if err != nil { diff --git a/internal/chatinput.go b/internal/chatinput.go index 9f816ea2..c359a40a 100644 --- a/internal/chatinput.go +++ b/internal/chatinput.go @@ -24,6 +24,14 @@ type ApprovalRequestMsg struct { Command string } +// FileSelectionRequestMsg is used to request file selection +type FileSelectionRequestMsg struct{} + +// FileSelectedMsg is used to indicate a file was selected +type FileSelectedMsg struct { + FilePath string +} + // ChatInputModel represents a persistent chat input interface type ChatInputModel struct { textarea []string @@ -96,6 +104,15 @@ func (m *ChatInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.approvalSelected = 0 // Start with first option selected return m, nil + case FileSelectedMsg: + fileRef := "@" + msg.FilePath + " " + currentLine := m.textarea[m.lineIndex] + before := currentLine[:m.cursor] + after := currentLine[m.cursor:] + m.textarea[m.lineIndex] = before + fileRef + after + m.cursor += len(fileRef) + return m, nil + case SetStatusMsg: m.statusMessage = msg.Message if msg.Spinner { @@ -244,6 +261,14 @@ func (m *ChatInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: if !m.focusOnHistory && len(msg.String()) == 1 && msg.String()[0] >= 32 { char := msg.String() + + // If user types "@", trigger file selector + if char == "@" { + return m, func() tea.Msg { + return FileSelectionRequestMsg{} + } + } + currentLine := m.textarea[m.lineIndex] before := currentLine[:m.cursor] after := currentLine[m.cursor:] diff --git a/internal/chatmanager.go b/internal/chatmanager.go new file mode 100644 index 00000000..42eed510 --- /dev/null +++ b/internal/chatmanager.go @@ -0,0 +1,238 @@ +package internal + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/bubbletea" +) + +// ChatManagerModel manages the overall chat interface including file selection +type ChatManagerModel struct { + chatInput *ChatInputModel + fileSelector *FileSelectorModel + showingFiles bool + width int + height int +} + +// NewChatManagerModel creates a new chat manager +func NewChatManagerModel() *ChatManagerModel { + return &ChatManagerModel{ + chatInput: NewChatInputModel(), + showingFiles: false, + width: 80, + height: 20, + } +} + +func (m *ChatManagerModel) Init() tea.Cmd { + return m.chatInput.Init() +} + +func (m *ChatManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.chatInput.Update(msg) + if m.fileSelector != nil { + m.fileSelector.Update(msg) + } + return m, nil + + case FileSelectionRequestMsg: + files, err := m.scanProjectFiles() + if err != nil { + return m.chatInput.Update(SetStatusMsg{ + Message: fmt.Sprintf("āŒ Error scanning files: %v", err), + Spinner: false, + }) + } + + if len(files) == 0 { + return m.chatInput.Update(SetStatusMsg{ + Message: "āŒ No files found in current directory", + Spinner: false, + }) + } + + maxFiles := 200 + if len(files) > maxFiles { + files = files[:maxFiles] + } + + m.fileSelector = NewFileSelectorModel(files) + m.showingFiles = true + return m, nil + + default: + if m.showingFiles && m.fileSelector != nil { + updatedSelector, cmd := m.fileSelector.Update(msg) + m.fileSelector = updatedSelector.(*FileSelectorModel) + + if m.fileSelector.IsDone() { + m.showingFiles = false + if m.fileSelector.IsSelected() && !m.fileSelector.IsCancelled() { + selectedFile := m.fileSelector.GetSelected() + return m.chatInput.Update(FileSelectedMsg{FilePath: selectedFile}) + } + + m.fileSelector = nil + } + + return m, cmd + } else { + updatedChat, cmd := m.chatInput.Update(msg) + m.chatInput = updatedChat.(*ChatInputModel) + return m, cmd + } + } +} + +func (m *ChatManagerModel) View() string { + if m.showingFiles && m.fileSelector != nil { + return m.fileSelector.View() + } + return m.chatInput.View() +} + +// Delegate methods to chat input +func (m *ChatManagerModel) HasInput() bool { + return m.chatInput.HasInput() +} + +func (m *ChatManagerModel) GetInput() string { + return m.chatInput.GetInput() +} + +func (m *ChatManagerModel) IsCancelled() bool { + return m.chatInput.IsCancelled() +} + +func (m *ChatManagerModel) ResetCancellation() { + m.chatInput.ResetCancellation() +} + +func (m *ChatManagerModel) IsQuitRequested() bool { + return m.chatInput.IsQuitRequested() +} + +func (m *ChatManagerModel) IsApprovalPending() bool { + return m.chatInput.IsApprovalPending() +} + +func (m *ChatManagerModel) GetApprovalResponse() int { + return m.chatInput.GetApprovalResponse() +} + +func (m *ChatManagerModel) ResetApproval() { + m.chatInput.ResetApproval() +} + +// GetChatInput returns the underlying chat input model +func (m *ChatManagerModel) GetChatInput() *ChatInputModel { + return m.chatInput +} + +// scanProjectFiles recursively scans the current directory for files +func (m *ChatManagerModel) scanProjectFiles() ([]string, error) { + var files []string + + excludeDirs := map[string]bool{ + ".git": true, + ".github": true, + "node_modules": true, + ".infer": true, + "vendor": true, + ".flox": true, + "dist": true, + "build": true, + "bin": true, + ".vscode": true, + ".idea": true, + } + + excludeExts := map[string]bool{ + ".exe": true, + ".bin": true, + ".dll": true, + ".so": true, + ".dylib": true, + ".a": true, + ".o": true, + ".obj": true, + ".pyc": true, + ".class": true, + ".jar": true, + ".war": true, + ".zip": true, + ".tar": true, + ".gz": true, + ".rar": true, + ".7z": true, + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, + ".bmp": true, + ".ico": true, + ".svg": true, + ".pdf": true, + ".mov": true, + ".mp4": true, + ".avi": true, + ".mp3": true, + ".wav": true, + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current directory: %w", err) + } + + err = filepath.WalkDir(cwd, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + relPath, err := filepath.Rel(cwd, path) + if err != nil { + return nil + } + + if d.IsDir() { + if excludeDirs[d.Name()] || strings.HasPrefix(d.Name(), ".") && d.Name() != "." { + return filepath.SkipDir + } + return nil + } + + ext := strings.ToLower(filepath.Ext(relPath)) + if excludeExts[ext] { + return nil + } + + if info, err := d.Info(); err == nil && info.Size() > 1024*1024 { + return nil + } + + if d.Type().IsRegular() { + files = append(files, relPath) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to scan directory: %w", err) + } + + sort.Strings(files) + + return files, nil +} diff --git a/internal/fileselector.go b/internal/fileselector.go new file mode 100644 index 00000000..9e354e90 --- /dev/null +++ b/internal/fileselector.go @@ -0,0 +1,202 @@ +package internal + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbletea" +) + +// FileSelectorModel represents a file selection interface +type FileSelectorModel struct { + files []string + filteredFiles []string + cursor int + searchQuery string + width int + height int + selected string + cancelled bool + done bool +} + +// NewFileSelectorModel creates a new file selector +func NewFileSelectorModel(files []string) *FileSelectorModel { + return &FileSelectorModel{ + files: files, + filteredFiles: files, + cursor: 0, + searchQuery: "", + width: 80, + height: 20, + selected: "", + cancelled: false, + done: false, + } +} + +func (m *FileSelectorModel) Init() tea.Cmd { + return nil +} + +func (m *FileSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.cancelled = true + m.done = true + return m, tea.Quit + + case "enter": + if len(m.filteredFiles) > 0 && m.cursor < len(m.filteredFiles) { + m.selected = m.filteredFiles[m.cursor] + m.done = true + return m, tea.Quit + } + return m, nil + + case "up": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case "down": + if m.cursor < len(m.filteredFiles)-1 { + m.cursor++ + } + return m, nil + + case "backspace": + if len(m.searchQuery) > 0 { + m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] + m.filterFiles() + m.adjustCursor() + } + return m, nil + + default: + if len(msg.String()) == 1 && msg.String()[0] >= 32 && msg.String()[0] <= 126 { + m.searchQuery += msg.String() + m.filterFiles() + m.adjustCursor() + } + return m, nil + } + } + + return m, nil +} + +func (m *FileSelectorModel) filterFiles() { + if m.searchQuery == "" { + m.filteredFiles = m.files + return + } + + var filtered []string + query := strings.ToLower(m.searchQuery) + + for _, file := range m.files { + fileLower := strings.ToLower(file) + if strings.Contains(fileLower, query) { + filtered = append(filtered, file) + } + } + + m.filteredFiles = filtered +} + +func (m *FileSelectorModel) adjustCursor() { + if m.cursor >= len(m.filteredFiles) { + if len(m.filteredFiles) > 0 { + m.cursor = len(m.filteredFiles) - 1 + } else { + m.cursor = 0 + } + } +} + +func (m *FileSelectorModel) View() string { + var b strings.Builder + + b.WriteString("šŸ“ Select a file to include (type to search, ESC to cancel)\n\n") + + searchBox := fmt.Sprintf("šŸ” Search: %s", m.searchQuery) + if len(m.searchQuery) == 0 { + searchBox += "│" + } + b.WriteString(searchBox + "\n") + b.WriteString(strings.Repeat("─", min(m.width, 60)) + "\n\n") + + if len(m.filteredFiles) != len(m.files) { + b.WriteString(fmt.Sprintf("Showing %d of %d files\n\n", len(m.filteredFiles), len(m.files))) + } + + if len(m.filteredFiles) == 0 { + b.WriteString("āŒ No files match your search\n") + } else { + maxVisible := min(15, m.height-8) + startIdx := 0 + endIdx := len(m.filteredFiles) + + if len(m.filteredFiles) > maxVisible { + if m.cursor >= maxVisible/2 { + startIdx = min(m.cursor-maxVisible/2, len(m.filteredFiles)-maxVisible) + endIdx = startIdx + maxVisible + } else { + endIdx = maxVisible + } + } + + for i := startIdx; i < endIdx && i < len(m.filteredFiles); i++ { + file := m.filteredFiles[i] + if i == m.cursor { + b.WriteString(fmt.Sprintf("ā–¶ \033[36;1m%s\033[0m\n", file)) + } else { + b.WriteString(fmt.Sprintf(" %s\n", file)) + } + } + + if len(m.filteredFiles) > maxVisible { + if startIdx > 0 { + b.WriteString("\n ↑ More files above\n") + } + if endIdx < len(m.filteredFiles) { + b.WriteString(" ↓ More files below\n") + } + } + } + + b.WriteString("\n") + b.WriteString(strings.Repeat("─", min(m.width, 60)) + "\n") + b.WriteString("šŸ’” \033[90mType to search • ↑↓ Navigate • Enter Select • Esc Cancel\033[0m") + + return b.String() +} + +// IsSelected returns true if a file was selected +func (m *FileSelectorModel) IsSelected() bool { + return m.done && !m.cancelled && m.selected != "" +} + +// IsCancelled returns true if selection was cancelled +func (m *FileSelectorModel) IsCancelled() bool { + return m.cancelled +} + +// GetSelected returns the selected file +func (m *FileSelectorModel) GetSelected() string { + return m.selected +} + +// IsDone returns true if selection process is complete +func (m *FileSelectorModel) IsDone() bool { + return m.done +}