1package tools
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "unicode/utf8"
12
13 "github.com/charmbracelet/crush/internal/ai"
14 "github.com/charmbracelet/crush/internal/lsp"
15 "github.com/charmbracelet/crush/internal/permission"
16)
17
18type ViewParams struct {
19 FilePath string `json:"file_path" description:"The path to the file to read"`
20 Offset int `json:"offset" description:"The line number to start reading from (0-based)"`
21 Limit int `json:"limit" description:"The number of lines to read (defaults to 2000)"`
22}
23
24type ViewPermissionsParams struct {
25 FilePath string `json:"file_path"`
26 Offset int `json:"offset"`
27 Limit int `json:"limit"`
28}
29
30type ViewResponseMetadata struct {
31 FilePath string `json:"file_path"`
32 Content string `json:"content"`
33}
34
35const (
36 ViewToolName = "view"
37)
38
39func NewViewTool(lspClients map[string]*lsp.Client, permissions permission.Service, workingDir string) ai.AgentTool {
40 return ai.NewTypedToolFunc(
41 ViewToolName,
42 `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
43
44WHEN TO USE THIS TOOL:
45- Use when you need to read the contents of a specific file
46- Helpful for examining source code, configuration files, or log files
47- Perfect for looking at text-based file formats
48
49HOW TO USE:
50- Provide the path to the file you want to view
51- Optionally specify an offset to start reading from a specific line
52- Optionally specify a limit to control how many lines are read
53- Do not use this for directories use the ls tool instead
54
55FEATURES:
56- Displays file contents with line numbers for easy reference
57- Can read from any position in a file using the offset parameter
58- Handles large files by limiting the number of lines read
59- Automatically truncates very long lines for better display
60- Suggests similar file names when the requested file isn't found
61
62LIMITATIONS:
63- Maximum file size is 250KB
64- Default reading limit is 2000 lines
65- Lines longer than 2000 characters are truncated
66- Cannot display binary files or images
67- Images can be identified but not displayed
68
69WINDOWS NOTES:
70- Handles both Windows (CRLF) and Unix (LF) line endings automatically
71- File paths work with both forward slashes (/) and backslashes (\)
72- Text encoding is detected automatically for most common formats
73
74TIPS:
75- Use with Glob tool to first find files you want to view
76- For code exploration, first use Grep to find relevant files, then View to examine them
77- When viewing large files, use the offset parameter to read specific sections`,
78 func(ctx context.Context, params ViewParams, call ai.ToolCall) (ai.ToolResponse, error) {
79 if params.FilePath == "" {
80 return ai.NewTextErrorResponse("file_path is required"), nil
81 }
82
83 // Handle relative paths
84 filePath := params.FilePath
85 if !filepath.IsAbs(filePath) {
86 filePath = filepath.Join(workingDir, filePath)
87 }
88
89 // Check if file is outside working directory and request permission if needed
90 absWorkingDir, err := filepath.Abs(workingDir)
91 if err != nil {
92 return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
93 }
94
95 absFilePath, err := filepath.Abs(filePath)
96 if err != nil {
97 return ai.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
98 }
99
100 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
101 if err != nil || strings.HasPrefix(relPath, "..") {
102 // File is outside working directory, request permission
103 sessionID, messageID := GetContextValues(ctx)
104 if sessionID == "" || messageID == "" {
105 return ai.ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
106 }
107
108 granted := permissions.Request(
109 permission.CreatePermissionRequest{
110 SessionID: sessionID,
111 Path: absFilePath,
112 ToolCallID: call.ID,
113 ToolName: ViewToolName,
114 Action: "read",
115 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
116 Params: ViewPermissionsParams(params),
117 },
118 )
119
120 if !granted {
121 return ai.ToolResponse{}, permission.ErrorPermissionDenied
122 }
123 }
124
125 // Check if file exists
126 fileInfo, err := os.Stat(filePath)
127 if err != nil {
128 if os.IsNotExist(err) {
129 // Try to offer suggestions for similarly named files
130 dir := filepath.Dir(filePath)
131 base := filepath.Base(filePath)
132
133 dirEntries, dirErr := os.ReadDir(dir)
134 if dirErr == nil {
135 var suggestions []string
136 for _, entry := range dirEntries {
137 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
138 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
139 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
140 if len(suggestions) >= 3 {
141 break
142 }
143 }
144 }
145
146 if len(suggestions) > 0 {
147 return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
148 filePath, strings.Join(suggestions, "\n"))), nil
149 }
150 }
151
152 return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
153 }
154 return ai.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
155 }
156
157 // Check if it's a directory
158 if fileInfo.IsDir() {
159 return ai.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
160 }
161
162 // Check file size
163 if fileInfo.Size() > MaxReadSize {
164 return ai.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
165 fileInfo.Size(), MaxReadSize)), nil
166 }
167
168 // Set default limit if not provided
169 if params.Limit <= 0 {
170 params.Limit = DefaultReadLimit
171 }
172
173 // Check if it's an image file
174 isImage, imageType := isImageFile(filePath)
175 // TODO: handle images
176 if isImage {
177 return ai.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
178 }
179
180 // Read the file content
181 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
182 isValidUt8 := utf8.ValidString(content)
183 if !isValidUt8 {
184 return ai.NewTextErrorResponse("File content is not valid UTF-8"), nil
185 }
186 if err != nil {
187 return ai.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
188 }
189
190 notifyLspOpenFile(ctx, filePath, lspClients)
191 output := "<file>\n"
192 // Format the output with line numbers
193 output += addLineNumbers(content, params.Offset+1)
194
195 // Add a note if the content was truncated
196 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
197 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
198 params.Offset+len(strings.Split(content, "\n")))
199 }
200 output += "\n</file>\n"
201 output += getDiagnostics(filePath, lspClients)
202 recordFileRead(filePath)
203 return ai.WithResponseMetadata(
204 ai.NewTextResponse(output),
205 ViewResponseMetadata{
206 FilePath: filePath,
207 Content: content,
208 },
209 ), nil
210 })
211}
212
213func addLineNumbers(content string, startLine int) string {
214 if content == "" {
215 return ""
216 }
217
218 lines := strings.Split(content, "\n")
219
220 var result []string
221 for i, line := range lines {
222 line = strings.TrimSuffix(line, "\r")
223
224 lineNum := i + startLine
225 numStr := fmt.Sprintf("%d", lineNum)
226
227 if len(numStr) >= 6 {
228 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
229 } else {
230 paddedNum := fmt.Sprintf("%6s", numStr)
231 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
232 }
233 }
234
235 return strings.Join(result, "\n")
236}
237
238func readTextFile(filePath string, offset, limit int) (string, int, error) {
239 file, err := os.Open(filePath)
240 if err != nil {
241 return "", 0, err
242 }
243 defer file.Close()
244
245 lineCount := 0
246
247 scanner := NewLineScanner(file)
248 if offset > 0 {
249 for lineCount < offset && scanner.Scan() {
250 lineCount++
251 }
252 if err = scanner.Err(); err != nil {
253 return "", 0, err
254 }
255 }
256
257 if offset == 0 {
258 _, err = file.Seek(0, io.SeekStart)
259 if err != nil {
260 return "", 0, err
261 }
262 }
263
264 // Pre-allocate slice with expected capacity
265 lines := make([]string, 0, limit)
266 lineCount = offset
267
268 for scanner.Scan() && len(lines) < limit {
269 lineCount++
270 lineText := scanner.Text()
271 if len(lineText) > MaxLineLength {
272 lineText = lineText[:MaxLineLength] + "..."
273 }
274 lines = append(lines, lineText)
275 }
276
277 // Continue scanning to get total line count
278 for scanner.Scan() {
279 lineCount++
280 }
281
282 if err := scanner.Err(); err != nil {
283 return "", 0, err
284 }
285
286 return strings.Join(lines, "\n"), lineCount, nil
287}
288
289func isImageFile(filePath string) (bool, string) {
290 ext := strings.ToLower(filepath.Ext(filePath))
291 switch ext {
292 case ".jpg", ".jpeg":
293 return true, "JPEG"
294 case ".png":
295 return true, "PNG"
296 case ".gif":
297 return true, "GIF"
298 case ".bmp":
299 return true, "BMP"
300 case ".svg":
301 return true, "SVG"
302 case ".webp":
303 return true, "WebP"
304 default:
305 return false, ""
306 }
307}
308
309type LineScanner struct {
310 scanner *bufio.Scanner
311}
312
313func NewLineScanner(r io.Reader) *LineScanner {
314 return &LineScanner{
315 scanner: bufio.NewScanner(r),
316 }
317}
318
319func (s *LineScanner) Scan() bool {
320 return s.scanner.Scan()
321}
322
323func (s *LineScanner) Text() string {
324 return s.scanner.Text()
325}
326
327func (s *LineScanner) Err() error {
328 return s.scanner.Err()
329}