view.go 7.8 KB

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