view.go

  1package tools
  2
  3import (
  4	"bufio"
  5	"context"
  6	_ "embed"
  7	"fmt"
  8	"io"
  9	"os"
 10	"path/filepath"
 11	"strings"
 12	"unicode/utf8"
 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      = 250 * 1024
 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			// Check if it's an image file
151			isImage, imageType := isImageFile(filePath)
152			// TODO: handle images
153			if isImage {
154				return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
155			}
156
157			// Read the file content
158			content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
159			isValidUt8 := utf8.ValidString(content)
160			if !isValidUt8 {
161				return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
162			}
163			if err != nil {
164				return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
165			}
166
167			notifyLSPs(ctx, lspClients, filePath)
168			output := "<file>\n"
169			// Format the output with line numbers
170			output += addLineNumbers(content, params.Offset+1)
171
172			// Add a note if the content was truncated
173			if lineCount > params.Offset+len(strings.Split(content, "\n")) {
174				output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
175					params.Offset+len(strings.Split(content, "\n")))
176			}
177			output += "\n</file>\n"
178			output += getDiagnostics(filePath, lspClients)
179			recordFileRead(filePath)
180			return fantasy.WithResponseMetadata(
181				fantasy.NewTextResponse(output),
182				ViewResponseMetadata{
183					FilePath: filePath,
184					Content:  content,
185				},
186			), nil
187		})
188}
189
190func addLineNumbers(content string, startLine int) string {
191	if content == "" {
192		return ""
193	}
194
195	lines := strings.Split(content, "\n")
196
197	var result []string
198	for i, line := range lines {
199		line = strings.TrimSuffix(line, "\r")
200
201		lineNum := i + startLine
202		numStr := fmt.Sprintf("%d", lineNum)
203
204		if len(numStr) >= 6 {
205			result = append(result, fmt.Sprintf("%s|%s", numStr, line))
206		} else {
207			paddedNum := fmt.Sprintf("%6s", numStr)
208			result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
209		}
210	}
211
212	return strings.Join(result, "\n")
213}
214
215func readTextFile(filePath string, offset, limit int) (string, int, error) {
216	file, err := os.Open(filePath)
217	if err != nil {
218		return "", 0, err
219	}
220	defer file.Close()
221
222	lineCount := 0
223
224	scanner := NewLineScanner(file)
225	if offset > 0 {
226		for lineCount < offset && scanner.Scan() {
227			lineCount++
228		}
229		if err = scanner.Err(); err != nil {
230			return "", 0, err
231		}
232	}
233
234	if offset == 0 {
235		_, err = file.Seek(0, io.SeekStart)
236		if err != nil {
237			return "", 0, err
238		}
239	}
240
241	// Pre-allocate slice with expected capacity
242	lines := make([]string, 0, limit)
243	lineCount = offset
244
245	for scanner.Scan() && len(lines) < limit {
246		lineCount++
247		lineText := scanner.Text()
248		if len(lineText) > MaxLineLength {
249			lineText = lineText[:MaxLineLength] + "..."
250		}
251		lines = append(lines, lineText)
252	}
253
254	// Continue scanning to get total line count
255	for scanner.Scan() {
256		lineCount++
257	}
258
259	if err := scanner.Err(); err != nil {
260		return "", 0, err
261	}
262
263	return strings.Join(lines, "\n"), lineCount, nil
264}
265
266func isImageFile(filePath string) (bool, string) {
267	ext := strings.ToLower(filepath.Ext(filePath))
268	switch ext {
269	case ".jpg", ".jpeg":
270		return true, "JPEG"
271	case ".png":
272		return true, "PNG"
273	case ".gif":
274		return true, "GIF"
275	case ".bmp":
276		return true, "BMP"
277	case ".svg":
278		return true, "SVG"
279	case ".webp":
280		return true, "WebP"
281	default:
282		return false, ""
283	}
284}
285
286type LineScanner struct {
287	scanner *bufio.Scanner
288}
289
290func NewLineScanner(r io.Reader) *LineScanner {
291	scanner := bufio.NewScanner(r)
292	// Increase buffer size to handle large lines (e.g., minified JSON, HTML)
293	// Default is 64KB, set to 1MB
294	buf := make([]byte, 0, 64*1024)
295	scanner.Buffer(buf, 1024*1024)
296	return &LineScanner{
297		scanner: scanner,
298	}
299}
300
301func (s *LineScanner) Scan() bool {
302	return s.scanner.Scan()
303}
304
305func (s *LineScanner) Text() string {
306	return s.scanner.Text()
307}
308
309func (s *LineScanner) Err() error {
310	return s.scanner.Err()
311}