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