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