view.go

  1package tools
  2
  3import (
  4	"bufio"
  5	"context"
  6	_ "embed"
  7	"encoding/base64"
  8	"fmt"
  9	"io"
 10	"os"
 11	"path/filepath"
 12	"strings"
 13	"unicode/utf8"
 14
 15	"charm.land/fantasy"
 16	"github.com/charmbracelet/crush/internal/csync"
 17	"github.com/charmbracelet/crush/internal/filepathext"
 18	"github.com/charmbracelet/crush/internal/lsp"
 19	"github.com/charmbracelet/crush/internal/permission"
 20)
 21
 22//go:embed view.md
 23var viewDescription []byte
 24
 25type ViewParams struct {
 26	FilePath string `json:"file_path" description:"The path to the file to read"`
 27	Offset   int    `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
 28	Limit    int    `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
 29}
 30
 31type ViewPermissionsParams struct {
 32	FilePath string `json:"file_path"`
 33	Offset   int    `json:"offset"`
 34	Limit    int    `json:"limit"`
 35}
 36
 37type viewTool struct {
 38	lspClients  *csync.Map[string, *lsp.Client]
 39	workingDir  string
 40	permissions permission.Service
 41}
 42
 43type ViewResponseMetadata struct {
 44	FilePath string `json:"file_path"`
 45	Content  string `json:"content"`
 46}
 47
 48const (
 49	ViewToolName     = "view"
 50	MaxReadSize      = 5 * 1024 * 1024 // 5MB
 51	DefaultReadLimit = 2000
 52	MaxLineLength    = 2000
 53)
 54
 55func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
 56	return fantasy.NewAgentTool(
 57		ViewToolName,
 58		string(viewDescription),
 59		func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 60			if params.FilePath == "" {
 61				return fantasy.NewTextErrorResponse("file_path is required"), nil
 62			}
 63
 64			// Handle relative paths
 65			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
 66
 67			// Check if file is outside working directory and request permission if needed
 68			absWorkingDir, err := filepath.Abs(workingDir)
 69			if err != nil {
 70				return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
 71			}
 72
 73			absFilePath, err := filepath.Abs(filePath)
 74			if err != nil {
 75				return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
 76			}
 77
 78			relPath, err := filepath.Rel(absWorkingDir, absFilePath)
 79			if err != nil || strings.HasPrefix(relPath, "..") {
 80				// File is outside working directory, request permission
 81				sessionID := GetSessionFromContext(ctx)
 82				if sessionID == "" {
 83					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
 84				}
 85
 86				granted := permissions.Request(
 87					permission.CreatePermissionRequest{
 88						SessionID:   sessionID,
 89						Path:        absFilePath,
 90						ToolCallID:  call.ID,
 91						ToolName:    ViewToolName,
 92						Action:      "read",
 93						Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
 94						Params:      ViewPermissionsParams(params),
 95					},
 96				)
 97
 98				if !granted {
 99					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
100				}
101			}
102
103			// Check if file exists
104			fileInfo, err := os.Stat(filePath)
105			if err != nil {
106				if os.IsNotExist(err) {
107					// Try to offer suggestions for similarly named files
108					dir := filepath.Dir(filePath)
109					base := filepath.Base(filePath)
110
111					dirEntries, dirErr := os.ReadDir(dir)
112					if dirErr == nil {
113						var suggestions []string
114						for _, entry := range dirEntries {
115							if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
116								strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
117								suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
118								if len(suggestions) >= 3 {
119									break
120								}
121							}
122						}
123
124						if len(suggestions) > 0 {
125							return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
126								filePath, strings.Join(suggestions, "\n"))), nil
127						}
128					}
129
130					return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
131				}
132				return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
133			}
134
135			// Check if it's a directory
136			if fileInfo.IsDir() {
137				return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
138			}
139
140			// Check file size
141			if fileInfo.Size() > MaxReadSize {
142				return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
143					fileInfo.Size(), MaxReadSize)), nil
144			}
145
146			// Set default limit if not provided
147			if params.Limit <= 0 {
148				params.Limit = DefaultReadLimit
149			}
150
151			isImage, mimeType := getImageMimeType(filePath)
152			if isImage {
153				if !GetSupportsImagesFromContext(ctx) {
154					modelName := GetModelNameFromContext(ctx)
155					return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
156				}
157
158				imageData, err := os.ReadFile(filePath)
159				if err != nil {
160					return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
161				}
162
163				encoded := base64.StdEncoding.EncodeToString(imageData)
164				return fantasy.NewImageResponse([]byte(encoded), mimeType), nil
165			}
166
167			// Read the file content
168			content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
169			isValidUt8 := utf8.ValidString(content)
170			if !isValidUt8 {
171				return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
172			}
173			if err != nil {
174				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
175			}
176
177			notifyLSPs(ctx, lspClients, filePath)
178			output := "<file>\n"
179			// Format the output with line numbers
180			output += addLineNumbers(content, params.Offset+1)
181
182			// Add a note if the content was truncated
183			if lineCount > params.Offset+len(strings.Split(content, "\n")) {
184				output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
185					params.Offset+len(strings.Split(content, "\n")))
186			}
187			output += "\n</file>\n"
188			output += getDiagnostics(filePath, lspClients)
189			recordFileRead(filePath)
190			return fantasy.WithResponseMetadata(
191				fantasy.NewTextResponse(output),
192				ViewResponseMetadata{
193					FilePath: filePath,
194					Content:  content,
195				},
196			), nil
197		})
198}
199
200func addLineNumbers(content string, startLine int) string {
201	if content == "" {
202		return ""
203	}
204
205	lines := strings.Split(content, "\n")
206
207	var result []string
208	for i, line := range lines {
209		line = strings.TrimSuffix(line, "\r")
210
211		lineNum := i + startLine
212		numStr := fmt.Sprintf("%d", lineNum)
213
214		if len(numStr) >= 6 {
215			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
216		} else {
217			paddedNum := fmt.Sprintf("%6s", numStr)
218			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
219		}
220	}
221
222	return strings.Join(result, "\n")
223}
224
225func readTextFile(filePath string, offset, limit int) (string, int, error) {
226	file, err := os.Open(filePath)
227	if err != nil {
228		return "", 0, err
229	}
230	defer file.Close()
231
232	lineCount := 0
233
234	scanner := NewLineScanner(file)
235	if offset > 0 {
236		for lineCount < offset && scanner.Scan() {
237			lineCount++
238		}
239		if err = scanner.Err(); err != nil {
240			return "", 0, err
241		}
242	}
243
244	if offset == 0 {
245		_, err = file.Seek(0, io.SeekStart)
246		if err != nil {
247			return "", 0, err
248		}
249	}
250
251	// Pre-allocate slice with expected capacity
252	lines := make([]string, 0, limit)
253	lineCount = offset
254
255	for scanner.Scan() && len(lines) < limit {
256		lineCount++
257		lineText := scanner.Text()
258		if len(lineText) > MaxLineLength {
259			lineText = lineText[:MaxLineLength] + "..."
260		}
261		lines = append(lines, lineText)
262	}
263
264	// Continue scanning to get total line count
265	for scanner.Scan() {
266		lineCount++
267	}
268
269	if err := scanner.Err(); err != nil {
270		return "", 0, err
271	}
272
273	return strings.Join(lines, "\n"), lineCount, nil
274}
275
276func getImageMimeType(filePath string) (bool, string) {
277	ext := strings.ToLower(filepath.Ext(filePath))
278	switch ext {
279	case ".jpg", ".jpeg":
280		return true, "image/jpeg"
281	case ".png":
282		return true, "image/png"
283	case ".gif":
284		return true, "image/gif"
285	case ".bmp":
286		return true, "image/bmp"
287	case ".svg":
288		return true, "image/svg+xml"
289	case ".webp":
290		return true, "image/webp"
291	default:
292		return false, ""
293	}
294}
295
296type LineScanner struct {
297	scanner *bufio.Scanner
298}
299
300func NewLineScanner(r io.Reader) *LineScanner {
301	scanner := bufio.NewScanner(r)
302	// Increase buffer size to handle large lines (e.g., minified JSON, HTML)
303	// Default is 64KB, set to 1MB
304	buf := make([]byte, 0, 64*1024)
305	scanner.Buffer(buf, 1024*1024)
306	return &LineScanner{
307		scanner: scanner,
308	}
309}
310
311func (s *LineScanner) Scan() bool {
312	return s.scanner.Scan()
313}
314
315func (s *LineScanner) Text() string {
316	return s.scanner.Text()
317}
318
319func (s *LineScanner) Err() error {
320	return s.scanner.Err()
321}