2
0

view.go 7.6 KB

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