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