Skip to content

Commit fd58a32

Browse files
committed
@ means read file
This is made popular by other prompts Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent d42c1b4 commit fd58a32

File tree

4 files changed

+302
-406
lines changed

4 files changed

+302
-406
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
)
10+
11+
// getFileSuggestions returns a list of files in the current or specified directory
12+
// that match the given prefix
13+
func getFileSuggestions(prefix string) []string {
14+
var suggestions []string
15+
16+
// Determine the directory to look in
17+
dir := "."
18+
basePath := prefix
19+
20+
// If prefix contains directory separators, split the path
21+
if strings.Contains(prefix, "/") || strings.Contains(prefix, string(os.PathSeparator)) {
22+
lastSep := strings.LastIndexAny(prefix, "/\\")
23+
if lastSep >= 0 {
24+
dir = prefix[:lastSep+1]
25+
basePath = prefix[lastSep+1:]
26+
}
27+
} else {
28+
basePath = prefix
29+
}
30+
31+
// Clean the directory path for security
32+
cleanDir := filepath.Clean(dir)
33+
34+
// Read the directory contents
35+
entries, err := os.ReadDir(cleanDir)
36+
if err != nil {
37+
return suggestions
38+
}
39+
40+
// Filter entries based on the prefix
41+
for _, entry := range entries {
42+
name := entry.Name()
43+
// Only include files/directories that match the prefix
44+
if strings.HasPrefix(name, basePath) {
45+
// If it's a directory, add a trailing slash
46+
if entry.IsDir() {
47+
suggestions = append(suggestions, filepath.Join(cleanDir, name)+string(os.PathSeparator))
48+
} else {
49+
suggestions = append(suggestions, filepath.Join(cleanDir, name))
50+
}
51+
}
52+
}
53+
54+
// Sort suggestions
55+
sort.Strings(suggestions)
56+
57+
// Format suggestions to be relative to the original prefix directory
58+
if strings.Contains(prefix, "/") || strings.Contains(prefix, string(os.PathSeparator)) {
59+
lastSep := strings.LastIndexAny(prefix, "/\\")
60+
if lastSep >= 0 {
61+
originalDir := prefix[:lastSep+1]
62+
for i, suggestion := range suggestions {
63+
// Remove the cleanDir prefix and add back the original directory prefix
64+
trimmed := strings.TrimPrefix(suggestion, cleanDir+"/")
65+
suggestions[i] = originalDir + trimmed
66+
}
67+
}
68+
}
69+
70+
return suggestions
71+
}
72+
73+
// printFileSuggestions prints file suggestions to stdout in a formatted way
74+
func printFileSuggestions(suggestions []string, prefix string) {
75+
if len(suggestions) == 0 {
76+
return
77+
}
78+
79+
fmt.Println() // New line before suggestions
80+
81+
// Calculate the number of columns to display
82+
maxLen := 0
83+
for _, s := range suggestions {
84+
if len(s) > maxLen {
85+
maxLen = len(s)
86+
}
87+
}
88+
89+
// For now, just print them in a simple list format
90+
for i, suggestion := range suggestions {
91+
fmt.Printf(" %s", strings.TrimPrefix(suggestion, "./")) // Remove ./ prefix for cleaner display
92+
if (i+1)%4 == 0 { // New line every 4 suggestions
93+
fmt.Println()
94+
} else {
95+
fmt.Print(" ") // Space between suggestions
96+
}
97+
}
98+
99+
if len(suggestions)%4 != 0 {
100+
fmt.Println() // New line if the last row wasn't complete
101+
}
102+
103+
// Print current position indicator
104+
fmt.Printf("▼\n")
105+
}

cmd/cli/commands/images.go

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,9 @@ func encodeImageToDataURL(filePath string) (string, error) {
9494
return "", err
9595
}
9696

97-
contentType := http.DetectContentType(buf)
98-
allowedTypes := []string{"image/jpeg", "image/png", "image/webp"}
99-
if !slices.Contains(allowedTypes, contentType) {
100-
return "", fmt.Errorf("invalid image type: %s", contentType)
97+
// Validate that this is an image file using both content and extension
98+
if !isImageFileByContentAndExtension(buf, filePath) {
99+
return "", fmt.Errorf("invalid image type for file: %s", filePath)
101100
}
102101

103102
info, err := file.Stat()
@@ -158,3 +157,112 @@ func processImagesInPrompt(prompt string) (string, []string, error) {
158157

159158
return strings.TrimSpace(prompt), imageDataURLs, nil
160159
}
160+
161+
// extractFileInclusions finds file paths in the prompt text using the @ symbol
162+
// e.g., @filename.txt, @./path/to/file.txt, @/absolute/path/file.txt
163+
func extractFileInclusions(prompt string) []string {
164+
// Regex to match @ followed by a file path
165+
// Pattern explanation:
166+
// - @ symbol before the path
167+
// - File path can be quoted (at least one char) or unquoted (at least one char)
168+
// - Supports relative (./, ../) and absolute paths
169+
regexPattern := `@(?:"([^"]+)"|'([^']+)'|([^\s"']+\b))`
170+
re := regexp.MustCompile(regexPattern)
171+
matches := re.FindAllStringSubmatch(prompt, -1)
172+
173+
paths := []string{}
174+
for _, match := range matches {
175+
// match[0] is the full match
176+
// match[1] is double-quoted content
177+
// match[2] is single-quoted content
178+
// match[3] is unquoted content
179+
if len(match) >= 2 && match[1] != "" {
180+
paths = append(paths, match[1])
181+
} else if len(match) >= 3 && match[2] != "" {
182+
paths = append(paths, match[2])
183+
} else if len(match) >= 4 && match[3] != "" {
184+
paths = append(paths, match[3])
185+
}
186+
}
187+
188+
return paths
189+
}
190+
191+
// hasValidImageExtension checks if the file has a valid image extension
192+
func hasValidImageExtension(filePath string) bool {
193+
filePathLower := strings.ToLower(filePath)
194+
return strings.HasSuffix(filePathLower, ".jpg") ||
195+
strings.HasSuffix(filePathLower, ".jpeg") ||
196+
strings.HasSuffix(filePathLower, ".png") ||
197+
strings.HasSuffix(filePathLower, ".webp")
198+
}
199+
200+
// isImageFileByContentAndExtension checks if a file is an image using both file extension and content type detection
201+
func isImageFileByContentAndExtension(content []byte, filePath string) bool {
202+
// First check content type
203+
contentType := http.DetectContentType(content)
204+
if strings.HasPrefix(contentType, "image/") {
205+
return true
206+
}
207+
208+
// Fallback to file extension check if content detection fails
209+
return hasValidImageExtension(filePath)
210+
}
211+
212+
// processFileInclusions extracts files mentioned with @ symbol, reads their contents,
213+
// and returns the prompt with file contents embedded
214+
func processFileInclusions(prompt string) (string, error) {
215+
filePaths := extractFileInclusions(prompt)
216+
217+
// Process each file inclusion in order
218+
for _, filePath := range filePaths {
219+
nfp := normalizeFilePath(filePath)
220+
221+
// Read the file content
222+
content, err := os.ReadFile(nfp)
223+
if err != nil {
224+
// Skip non-existent files or files that can't be read
225+
continue
226+
}
227+
228+
// Check if the file is an image to handle it appropriately
229+
// Only content is checked here, not file extension, to maintain original behavior
230+
if isImageFileByContentAndExtension(content, nfp) {
231+
// For image files, we keep the original file reference (@filePath) in the prompt
232+
// so that processImagesInPrompt can handle it properly
233+
continue
234+
}
235+
236+
// For non-image files, replace the @filename with the file content
237+
// Try different variations to match how it appears in the prompt
238+
escapedPath := regexp.QuoteMeta(filePath) // Escape special regex chars
239+
quotedPath := regexp.QuoteMeta(nfp) // Also try normalized path
240+
241+
// Replace all occurrences of the file reference with its content
242+
contentStr := string(content)
243+
244+
// Replace @ symbol usage with the file content - preserve the space or end by using word boundaries
245+
// Use capturing groups to preserve the space
246+
prompt = regexp.MustCompile(`@`+`"`+escapedPath+`"`).ReplaceAllString(prompt, contentStr)
247+
prompt = regexp.MustCompile(`@`+`'`+escapedPath+`'`).ReplaceAllString(prompt, contentStr)
248+
// For the unquoted version, we need to match @ + path and replace with content + the boundary character
249+
// Use a more specific pattern that captures the space or end
250+
prompt = regexp.MustCompile(`(@`+escapedPath+`)(\s|$)`).ReplaceAllString(prompt, contentStr+"$2")
251+
252+
// Also try replacing with normalized path
253+
prompt = regexp.MustCompile(`@`+`"`+quotedPath+`"`).ReplaceAllString(prompt, contentStr)
254+
prompt = regexp.MustCompile(`@`+`'`+quotedPath+`'`).ReplaceAllString(prompt, contentStr)
255+
prompt = regexp.MustCompile(`(@`+quotedPath+`)(\s|$)`).ReplaceAllString(prompt, contentStr+"$2")
256+
}
257+
258+
return prompt, nil
259+
}
260+
261+
// For testing purposes - making the functions public so they can be tested
262+
func ExtractFileInclusions(prompt string) []string {
263+
return extractFileInclusions(prompt)
264+
}
265+
266+
func ProcessFileInclusions(prompt string) (string, error) {
267+
return processFileInclusions(prompt)
268+
}

0 commit comments

Comments
 (0)