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