view.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. package tools
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "unicode/utf8"
  12. "github.com/charmbracelet/crush/internal/lsp"
  13. "github.com/charmbracelet/crush/internal/permission"
  14. )
  15. type ViewParams struct {
  16. FilePath string `json:"file_path"`
  17. Offset int `json:"offset"`
  18. Limit int `json:"limit"`
  19. }
  20. type ViewPermissionsParams struct {
  21. FilePath string `json:"file_path"`
  22. Offset int `json:"offset"`
  23. Limit int `json:"limit"`
  24. }
  25. type viewTool struct {
  26. lspClients map[string]*lsp.Client
  27. workingDir string
  28. permissions permission.Service
  29. }
  30. type ViewResponseMetadata struct {
  31. FilePath string `json:"file_path"`
  32. Content string `json:"content"`
  33. }
  34. const (
  35. ViewToolName = "view"
  36. MaxReadSize = 250 * 1024
  37. DefaultReadLimit = 2000
  38. MaxLineLength = 2000
  39. viewDescription = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
  40. WHEN TO USE THIS TOOL:
  41. - Use when you need to read the contents of a specific file
  42. - Helpful for examining source code, configuration files, or log files
  43. - Perfect for looking at text-based file formats
  44. HOW TO USE:
  45. - Provide the path to the file you want to view
  46. - Optionally specify an offset to start reading from a specific line
  47. - Optionally specify a limit to control how many lines are read
  48. - Do not use this for directories use the ls tool instead
  49. FEATURES:
  50. - Displays file contents with line numbers for easy reference
  51. - Can read from any position in a file using the offset parameter
  52. - Handles large files by limiting the number of lines read
  53. - Automatically truncates very long lines for better display
  54. - Suggests similar file names when the requested file isn't found
  55. LIMITATIONS:
  56. - Maximum file size is 250KB
  57. - Default reading limit is 2000 lines
  58. - Lines longer than 2000 characters are truncated
  59. - Cannot display binary files or images
  60. - Images can be identified but not displayed
  61. WINDOWS NOTES:
  62. - Handles both Windows (CRLF) and Unix (LF) line endings automatically
  63. - File paths work with both forward slashes (/) and backslashes (\)
  64. - Text encoding is detected automatically for most common formats
  65. TIPS:
  66. - Use with Glob tool to first find files you want to view
  67. - For code exploration, first use Grep to find relevant files, then View to examine them
  68. - When viewing large files, use the offset parameter to read specific sections`
  69. )
  70. func NewViewTool(lspClients map[string]*lsp.Client, permissions permission.Service, workingDir string) BaseTool {
  71. return &viewTool{
  72. lspClients: lspClients,
  73. workingDir: workingDir,
  74. permissions: permissions,
  75. }
  76. }
  77. func (v *viewTool) Name() string {
  78. return ViewToolName
  79. }
  80. func (v *viewTool) Info() ToolInfo {
  81. return ToolInfo{
  82. Name: ViewToolName,
  83. Description: viewDescription,
  84. Parameters: map[string]any{
  85. "file_path": map[string]any{
  86. "type": "string",
  87. "description": "The path to the file to read",
  88. },
  89. "offset": map[string]any{
  90. "type": "integer",
  91. "description": "The line number to start reading from (0-based)",
  92. },
  93. "limit": map[string]any{
  94. "type": "integer",
  95. "description": "The number of lines to read (defaults to 2000)",
  96. },
  97. },
  98. Required: []string{"file_path"},
  99. }
  100. }
  101. // Run implements Tool.
  102. func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  103. var params ViewParams
  104. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  105. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  106. }
  107. if params.FilePath == "" {
  108. return NewTextErrorResponse("file_path is required"), nil
  109. }
  110. // Handle relative paths
  111. filePath := params.FilePath
  112. if !filepath.IsAbs(filePath) {
  113. filePath = filepath.Join(v.workingDir, filePath)
  114. }
  115. // Check if file is outside working directory and request permission if needed
  116. absWorkingDir, err := filepath.Abs(v.workingDir)
  117. if err != nil {
  118. return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
  119. }
  120. absFilePath, err := filepath.Abs(filePath)
  121. if err != nil {
  122. return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
  123. }
  124. relPath, err := filepath.Rel(absWorkingDir, absFilePath)
  125. if err != nil || strings.HasPrefix(relPath, "..") {
  126. // File is outside working directory, request permission
  127. sessionID, messageID := GetContextValues(ctx)
  128. if sessionID == "" || messageID == "" {
  129. return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
  130. }
  131. granted := v.permissions.Request(
  132. permission.CreatePermissionRequest{
  133. SessionID: sessionID,
  134. Path: absFilePath,
  135. ToolCallID: call.ID,
  136. ToolName: ViewToolName,
  137. Action: "read",
  138. Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
  139. Params: ViewPermissionsParams(params),
  140. },
  141. )
  142. if !granted {
  143. return ToolResponse{}, permission.ErrorPermissionDenied
  144. }
  145. }
  146. // Check if file exists
  147. fileInfo, err := os.Stat(filePath)
  148. if err != nil {
  149. if os.IsNotExist(err) {
  150. // Try to offer suggestions for similarly named files
  151. dir := filepath.Dir(filePath)
  152. base := filepath.Base(filePath)
  153. dirEntries, dirErr := os.ReadDir(dir)
  154. if dirErr == nil {
  155. var suggestions []string
  156. for _, entry := range dirEntries {
  157. if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
  158. strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
  159. suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
  160. if len(suggestions) >= 3 {
  161. break
  162. }
  163. }
  164. }
  165. if len(suggestions) > 0 {
  166. return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
  167. filePath, strings.Join(suggestions, "\n"))), nil
  168. }
  169. }
  170. return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
  171. }
  172. return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
  173. }
  174. // Check if it's a directory
  175. if fileInfo.IsDir() {
  176. return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  177. }
  178. // Check file size
  179. if fileInfo.Size() > MaxReadSize {
  180. return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
  181. fileInfo.Size(), MaxReadSize)), nil
  182. }
  183. // Set default limit if not provided
  184. if params.Limit <= 0 {
  185. params.Limit = DefaultReadLimit
  186. }
  187. // Check if it's an image file
  188. isImage, imageType := isImageFile(filePath)
  189. // TODO: handle images
  190. if isImage {
  191. return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
  192. }
  193. // Read the file content
  194. content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
  195. isValidUt8 := utf8.ValidString(content)
  196. if !isValidUt8 {
  197. return NewTextErrorResponse("File content is not valid UTF-8"), nil
  198. }
  199. if err != nil {
  200. return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
  201. }
  202. notifyLspOpenFile(ctx, filePath, v.lspClients)
  203. output := "<file>\n"
  204. // Format the output with line numbers
  205. output += addLineNumbers(content, params.Offset+1)
  206. // Add a note if the content was truncated
  207. if lineCount > params.Offset+len(strings.Split(content, "\n")) {
  208. output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
  209. params.Offset+len(strings.Split(content, "\n")))
  210. }
  211. output += "\n</file>\n"
  212. output += getDiagnostics(filePath, v.lspClients)
  213. recordFileRead(filePath)
  214. return WithResponseMetadata(
  215. NewTextResponse(output),
  216. ViewResponseMetadata{
  217. FilePath: filePath,
  218. Content: content,
  219. },
  220. ), nil
  221. }
  222. func addLineNumbers(content string, startLine int) string {
  223. if content == "" {
  224. return ""
  225. }
  226. lines := strings.Split(content, "\n")
  227. var result []string
  228. for i, line := range lines {
  229. line = strings.TrimSuffix(line, "\r")
  230. lineNum := i + startLine
  231. numStr := fmt.Sprintf("%d", lineNum)
  232. if len(numStr) >= 6 {
  233. result = append(result, fmt.Sprintf("%s|%s", numStr, line))
  234. } else {
  235. paddedNum := fmt.Sprintf("%6s", numStr)
  236. result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
  237. }
  238. }
  239. return strings.Join(result, "\n")
  240. }
  241. func readTextFile(filePath string, offset, limit int) (string, int, error) {
  242. file, err := os.Open(filePath)
  243. if err != nil {
  244. return "", 0, err
  245. }
  246. defer file.Close()
  247. lineCount := 0
  248. scanner := NewLineScanner(file)
  249. if offset > 0 {
  250. for lineCount < offset && scanner.Scan() {
  251. lineCount++
  252. }
  253. if err = scanner.Err(); err != nil {
  254. return "", 0, err
  255. }
  256. }
  257. if offset == 0 {
  258. _, err = file.Seek(0, io.SeekStart)
  259. if err != nil {
  260. return "", 0, err
  261. }
  262. }
  263. // Pre-allocate slice with expected capacity
  264. lines := make([]string, 0, limit)
  265. lineCount = offset
  266. for scanner.Scan() && len(lines) < limit {
  267. lineCount++
  268. lineText := scanner.Text()
  269. if len(lineText) > MaxLineLength {
  270. lineText = lineText[:MaxLineLength] + "..."
  271. }
  272. lines = append(lines, lineText)
  273. }
  274. // Continue scanning to get total line count
  275. for scanner.Scan() {
  276. lineCount++
  277. }
  278. if err := scanner.Err(); err != nil {
  279. return "", 0, err
  280. }
  281. return strings.Join(lines, "\n"), lineCount, nil
  282. }
  283. func isImageFile(filePath string) (bool, string) {
  284. ext := strings.ToLower(filepath.Ext(filePath))
  285. switch ext {
  286. case ".jpg", ".jpeg":
  287. return true, "JPEG"
  288. case ".png":
  289. return true, "PNG"
  290. case ".gif":
  291. return true, "GIF"
  292. case ".bmp":
  293. return true, "BMP"
  294. case ".svg":
  295. return true, "SVG"
  296. case ".webp":
  297. return true, "WebP"
  298. default:
  299. return false, ""
  300. }
  301. }
  302. type LineScanner struct {
  303. scanner *bufio.Scanner
  304. }
  305. func NewLineScanner(r io.Reader) *LineScanner {
  306. return &LineScanner{
  307. scanner: bufio.NewScanner(r),
  308. }
  309. }
  310. func (s *LineScanner) Scan() bool {
  311. return s.scanner.Scan()
  312. }
  313. func (s *LineScanner) Text() string {
  314. return s.scanner.Text()
  315. }
  316. func (s *LineScanner) Err() error {
  317. return s.scanner.Err()
  318. }