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}