view.go

  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}