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