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