view.go

  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), &params); 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}