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}