view.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package tools
  2. import (
  3. "bufio"
  4. "context"
  5. _ "embed"
  6. "fmt"
  7. "io"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "unicode/utf8"
  12. "charm.land/fantasy"
  13. "github.com/charmbracelet/crush/internal/csync"
  14. "github.com/charmbracelet/crush/internal/filepathext"
  15. "github.com/charmbracelet/crush/internal/lsp"
  16. "github.com/charmbracelet/crush/internal/permission"
  17. )
  18. //go:embed view.md
  19. var viewDescription []byte
  20. type ViewParams struct {
  21. FilePath string `json:"file_path" description:"The path to the file to read"`
  22. Offset int `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
  23. Limit int `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
  24. }
  25. type ViewPermissionsParams struct {
  26. FilePath string `json:"file_path"`
  27. Offset int `json:"offset"`
  28. Limit int `json:"limit"`
  29. }
  30. type viewTool struct {
  31. lspClients *csync.Map[string, *lsp.Client]
  32. workingDir string
  33. permissions permission.Service
  34. }
  35. type ViewResponseMetadata struct {
  36. FilePath string `json:"file_path"`
  37. Content string `json:"content"`
  38. }
  39. const (
  40. ViewToolName = "view"
  41. MaxReadSize = 250 * 1024
  42. DefaultReadLimit = 2000
  43. MaxLineLength = 2000
  44. )
  45. func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
  46. return fantasy.NewAgentTool(
  47. ViewToolName,
  48. string(viewDescription),
  49. func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  50. if params.FilePath == "" {
  51. return fantasy.NewTextErrorResponse("file_path is required"), nil
  52. }
  53. // Handle relative paths
  54. filePath := filepathext.SmartJoin(workingDir, params.FilePath)
  55. // Check if file is outside working directory and request permission if needed
  56. absWorkingDir, err := filepath.Abs(workingDir)
  57. if err != nil {
  58. return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
  59. }
  60. absFilePath, err := filepath.Abs(filePath)
  61. if err != nil {
  62. return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
  63. }
  64. relPath, err := filepath.Rel(absWorkingDir, absFilePath)
  65. if err != nil || strings.HasPrefix(relPath, "..") {
  66. // File is outside working directory, request permission
  67. sessionID := GetSessionFromContext(ctx)
  68. if sessionID == "" {
  69. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
  70. }
  71. granted := permissions.Request(
  72. permission.CreatePermissionRequest{
  73. SessionID: sessionID,
  74. Path: absFilePath,
  75. ToolCallID: call.ID,
  76. ToolName: ViewToolName,
  77. Action: "read",
  78. Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
  79. Params: ViewPermissionsParams(params),
  80. },
  81. )
  82. if !granted {
  83. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  84. }
  85. }
  86. // Check if file exists
  87. fileInfo, err := os.Stat(filePath)
  88. if err != nil {
  89. if os.IsNotExist(err) {
  90. // Try to offer suggestions for similarly named files
  91. dir := filepath.Dir(filePath)
  92. base := filepath.Base(filePath)
  93. dirEntries, dirErr := os.ReadDir(dir)
  94. if dirErr == nil {
  95. var suggestions []string
  96. for _, entry := range dirEntries {
  97. if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
  98. strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
  99. suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
  100. if len(suggestions) >= 3 {
  101. break
  102. }
  103. }
  104. }
  105. if len(suggestions) > 0 {
  106. return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
  107. filePath, strings.Join(suggestions, "\n"))), nil
  108. }
  109. }
  110. return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
  111. }
  112. return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
  113. }
  114. // Check if it's a directory
  115. if fileInfo.IsDir() {
  116. return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  117. }
  118. // Check file size
  119. if fileInfo.Size() > MaxReadSize {
  120. return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
  121. fileInfo.Size(), MaxReadSize)), nil
  122. }
  123. // Set default limit if not provided
  124. if params.Limit <= 0 {
  125. params.Limit = DefaultReadLimit
  126. }
  127. // Check if it's an image file
  128. isImage, imageType := isImageFile(filePath)
  129. // TODO: handle images
  130. if isImage {
  131. return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
  132. }
  133. // Read the file content
  134. content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
  135. isValidUt8 := utf8.ValidString(content)
  136. if !isValidUt8 {
  137. return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
  138. }
  139. if err != nil {
  140. return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
  141. }
  142. notifyLSPs(ctx, lspClients, filePath)
  143. output := "<file>\n"
  144. // Format the output with line numbers
  145. output += addLineNumbers(content, params.Offset+1)
  146. // Add a note if the content was truncated
  147. if lineCount > params.Offset+len(strings.Split(content, "\n")) {
  148. output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
  149. params.Offset+len(strings.Split(content, "\n")))
  150. }
  151. output += "\n</file>\n"
  152. output += getDiagnostics(filePath, lspClients)
  153. recordFileRead(filePath)
  154. return fantasy.WithResponseMetadata(
  155. fantasy.NewTextResponse(output),
  156. ViewResponseMetadata{
  157. FilePath: filePath,
  158. Content: content,
  159. },
  160. ), nil
  161. })
  162. }
  163. func addLineNumbers(content string, startLine int) string {
  164. if content == "" {
  165. return ""
  166. }
  167. lines := strings.Split(content, "\n")
  168. var result []string
  169. for i, line := range lines {
  170. line = strings.TrimSuffix(line, "\r")
  171. lineNum := i + startLine
  172. numStr := fmt.Sprintf("%d", lineNum)
  173. if len(numStr) >= 6 {
  174. result = append(result, fmt.Sprintf("%s|%s", numStr, line))
  175. } else {
  176. paddedNum := fmt.Sprintf("%6s", numStr)
  177. result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
  178. }
  179. }
  180. return strings.Join(result, "\n")
  181. }
  182. func readTextFile(filePath string, offset, limit int) (string, int, error) {
  183. file, err := os.Open(filePath)
  184. if err != nil {
  185. return "", 0, err
  186. }
  187. defer file.Close()
  188. lineCount := 0
  189. scanner := NewLineScanner(file)
  190. if offset > 0 {
  191. for lineCount < offset && scanner.Scan() {
  192. lineCount++
  193. }
  194. if err = scanner.Err(); err != nil {
  195. return "", 0, err
  196. }
  197. }
  198. if offset == 0 {
  199. _, err = file.Seek(0, io.SeekStart)
  200. if err != nil {
  201. return "", 0, err
  202. }
  203. }
  204. // Pre-allocate slice with expected capacity
  205. lines := make([]string, 0, limit)
  206. lineCount = offset
  207. for scanner.Scan() && len(lines) < limit {
  208. lineCount++
  209. lineText := scanner.Text()
  210. if len(lineText) > MaxLineLength {
  211. lineText = lineText[:MaxLineLength] + "..."
  212. }
  213. lines = append(lines, lineText)
  214. }
  215. // Continue scanning to get total line count
  216. for scanner.Scan() {
  217. lineCount++
  218. }
  219. if err := scanner.Err(); err != nil {
  220. return "", 0, err
  221. }
  222. return strings.Join(lines, "\n"), lineCount, nil
  223. }
  224. func isImageFile(filePath string) (bool, string) {
  225. ext := strings.ToLower(filepath.Ext(filePath))
  226. switch ext {
  227. case ".jpg", ".jpeg":
  228. return true, "JPEG"
  229. case ".png":
  230. return true, "PNG"
  231. case ".gif":
  232. return true, "GIF"
  233. case ".bmp":
  234. return true, "BMP"
  235. case ".svg":
  236. return true, "SVG"
  237. case ".webp":
  238. return true, "WebP"
  239. default:
  240. return false, ""
  241. }
  242. }
  243. type LineScanner struct {
  244. scanner *bufio.Scanner
  245. }
  246. func NewLineScanner(r io.Reader) *LineScanner {
  247. scanner := bufio.NewScanner(r)
  248. // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
  249. // Default is 64KB, set to 1MB
  250. buf := make([]byte, 0, 64*1024)
  251. scanner.Buffer(buf, 1024*1024)
  252. return &LineScanner{
  253. scanner: scanner,
  254. }
  255. }
  256. func (s *LineScanner) Scan() bool {
  257. return s.scanner.Scan()
  258. }
  259. func (s *LineScanner) Text() string {
  260. return s.scanner.Text()
  261. }
  262. func (s *LineScanner) Err() error {
  263. return s.scanner.Err()
  264. }