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"
23var viewDescription []byte
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)"`
31type ViewPermissionsParams struct {
32 FilePath string `json:"file_path"`
33 Offset int `json:"offset"`
34 Limit int `json:"limit"`
38 lspClients *csync.Map[string, *lsp.Client]
40 permissions permission.Service
43type ViewResponseMetadata struct {
44 FilePath string `json:"file_path"`
45 Content string `json:"content"`
50 MaxReadSize = 5 * 1024 * 1024 // 5MB
51 DefaultReadLimit = 2000
55func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
56 return fantasy.NewAgentTool(
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
64 // Handle relative paths
65 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
67 // Check if file is outside working directory and request permission if needed
68 absWorkingDir, err := filepath.Abs(workingDir)
70 return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
73 absFilePath, err := filepath.Abs(filePath)
75 return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
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)
83 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
86 granted := permissions.Request(
87 permission.CreatePermissionRequest{
91 ToolName: ViewToolName,
93 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
94 Params: ViewPermissionsParams(params),
99 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
103 // Check if file exists
104 fileInfo, err := os.Stat(filePath)
106 if os.IsNotExist(err) {
107 // Try to offer suggestions for similarly named files
108 dir := filepath.Dir(filePath)
109 base := filepath.Base(filePath)
111 dirEntries, dirErr := os.ReadDir(dir)
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 {
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
130 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
132 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
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
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
146 // Set default limit if not provided
147 if params.Limit <= 0 {
148 params.Limit = DefaultReadLimit
151 isImage, mimeType := getImageMimeType(filePath)
153 if !GetSupportsImagesFromContext(ctx) {
154 modelName := GetModelNameFromContext(ctx)
155 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
158 imageData, err := os.ReadFile(filePath)
160 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
163 encoded := base64.StdEncoding.EncodeToString(imageData)
164 return fantasy.NewImageResponse([]byte(encoded), mimeType), nil
167 // Read the file content
168 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
169 isValidUt8 := utf8.ValidString(content)
171 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
174 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
177 notifyLSPs(ctx, lspClients, filePath)
179 // Format the output with line numbers
180 output += addLineNumbers(content, params.Offset+1)
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")))
187 output += "\n</file>\n"
188 output += getDiagnostics(filePath, lspClients)
189 recordFileRead(filePath)
190 return fantasy.WithResponseMetadata(
191 fantasy.NewTextResponse(output),
192 ViewResponseMetadata{
200func addLineNumbers(content string, startLine int) string {
205 lines := strings.Split(content, "\n")
208 for i, line := range lines {
209 line = strings.TrimSuffix(line, "\r")
211 lineNum := i + startLine
212 numStr := fmt.Sprintf("%d", lineNum)
214 if len(numStr) >= 6 {
215 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
217 paddedNum := fmt.Sprintf("%6s", numStr)
218 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
222 return strings.Join(result, "\n")
225func readTextFile(filePath string, offset, limit int) (string, int, error) {
226 file, err := os.Open(filePath)
234 scanner := NewLineScanner(file)
236 for lineCount < offset && scanner.Scan() {
239 if err = scanner.Err(); err != nil {
245 _, err = file.Seek(0, io.SeekStart)
251 // Pre-allocate slice with expected capacity
252 lines := make([]string, 0, limit)
255 for scanner.Scan() && len(lines) < limit {
257 lineText := scanner.Text()
258 if len(lineText) > MaxLineLength {
259 lineText = lineText[:MaxLineLength] + "..."
261 lines = append(lines, lineText)
264 // Continue scanning to get total line count
269 if err := scanner.Err(); err != nil {
273 return strings.Join(lines, "\n"), lineCount, nil
276func getImageMimeType(filePath string) (bool, string) {
277 ext := strings.ToLower(filepath.Ext(filePath))
279 case ".jpg", ".jpeg":
280 return true, "image/jpeg"
282 return true, "image/png"
284 return true, "image/gif"
286 return true, "image/bmp"
288 return true, "image/svg+xml"
290 return true, "image/webp"
296type LineScanner struct {
297 scanner *bufio.Scanner
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)
311func (s *LineScanner) Scan() bool {
312 return s.scanner.Scan()
315func (s *LineScanner) Text() string {
316 return s.scanner.Text()
319func (s *LineScanner) Err() error {
320 return s.scanner.Err()
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list