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