| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- package tools
- import (
- "bufio"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/lsp"
- )
- type ViewParams struct {
- FilePath string `json:"file_path"`
- Offset int `json:"offset"`
- Limit int `json:"limit"`
- }
- type viewTool struct {
- lspClients map[string]*lsp.Client
- }
- type ViewResponseMetadata struct {
- FilePath string `json:"file_path"`
- Content string `json:"content"`
- }
- const (
- ViewToolName = "view"
- MaxReadSize = 250 * 1024
- DefaultReadLimit = 2000
- MaxLineLength = 2000
- viewDescription = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
- WHEN TO USE THIS TOOL:
- - Use when you need to read the contents of a specific file
- - Helpful for examining source code, configuration files, or log files
- - Perfect for looking at text-based file formats
- HOW TO USE:
- - Provide the path to the file you want to view
- - Optionally specify an offset to start reading from a specific line
- - Optionally specify a limit to control how many lines are read
- FEATURES:
- - Displays file contents with line numbers for easy reference
- - Can read from any position in a file using the offset parameter
- - Handles large files by limiting the number of lines read
- - Automatically truncates very long lines for better display
- - Suggests similar file names when the requested file isn't found
- LIMITATIONS:
- - Maximum file size is 250KB
- - Default reading limit is 2000 lines
- - Lines longer than 2000 characters are truncated
- - Cannot display binary files or images
- - Images can be identified but not displayed
- TIPS:
- - Use with Glob tool to first find files you want to view
- - For code exploration, first use Grep to find relevant files, then View to examine them
- - When viewing large files, use the offset parameter to read specific sections`
- )
- func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
- return &viewTool{
- lspClients,
- }
- }
- func (v *viewTool) Info() ToolInfo {
- return ToolInfo{
- Name: ViewToolName,
- Description: viewDescription,
- Parameters: map[string]any{
- "file_path": map[string]any{
- "type": "string",
- "description": "The path to the file to read",
- },
- "offset": map[string]any{
- "type": "integer",
- "description": "The line number to start reading from (0-based)",
- },
- "limit": map[string]any{
- "type": "integer",
- "description": "The number of lines to read (defaults to 2000)",
- },
- },
- Required: []string{"file_path"},
- }
- }
- // Run implements Tool.
- func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
- var params ViewParams
- if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
- return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
- }
- if params.FilePath == "" {
- return NewTextErrorResponse("file_path is required"), nil
- }
- // Handle relative paths
- filePath := params.FilePath
- if !filepath.IsAbs(filePath) {
- filePath = filepath.Join(config.WorkingDirectory(), filePath)
- }
- // Check if file exists
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- // Try to offer suggestions for similarly named files
- dir := filepath.Dir(filePath)
- base := filepath.Base(filePath)
- dirEntries, dirErr := os.ReadDir(dir)
- if dirErr == nil {
- var suggestions []string
- for _, entry := range dirEntries {
- if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
- strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
- suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
- if len(suggestions) >= 3 {
- break
- }
- }
- }
- if len(suggestions) > 0 {
- return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
- filePath, strings.Join(suggestions, "\n"))), nil
- }
- }
- return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
- }
- return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
- }
- // Check if it's a directory
- if fileInfo.IsDir() {
- return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
- }
- // Check file size
- if fileInfo.Size() > MaxReadSize {
- return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
- fileInfo.Size(), MaxReadSize)), nil
- }
- // Set default limit if not provided
- if params.Limit <= 0 {
- params.Limit = DefaultReadLimit
- }
- // Check if it's an image file
- isImage, imageType := isImageFile(filePath)
- // TODO: handle images
- if isImage {
- return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
- }
- // Read the file content
- content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
- if err != nil {
- return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
- }
- notifyLspOpenFile(ctx, filePath, v.lspClients)
- output := "<file>\n"
- // Format the output with line numbers
- output += addLineNumbers(content, params.Offset+1)
- // Add a note if the content was truncated
- if lineCount > params.Offset+len(strings.Split(content, "\n")) {
- output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
- params.Offset+len(strings.Split(content, "\n")))
- }
- output += "\n</file>\n"
- output += getDiagnostics(filePath, v.lspClients)
- recordFileRead(filePath)
- return WithResponseMetadata(
- NewTextResponse(output),
- ViewResponseMetadata{
- FilePath: filePath,
- Content: content,
- },
- ), nil
- }
- func addLineNumbers(content string, startLine int) string {
- if content == "" {
- return ""
- }
- lines := strings.Split(content, "\n")
- var result []string
- for i, line := range lines {
- line = strings.TrimSuffix(line, "\r")
- lineNum := i + startLine
- numStr := fmt.Sprintf("%d", lineNum)
- if len(numStr) >= 6 {
- result = append(result, fmt.Sprintf("%s|%s", numStr, line))
- } else {
- paddedNum := fmt.Sprintf("%6s", numStr)
- result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
- }
- }
- return strings.Join(result, "\n")
- }
- func readTextFile(filePath string, offset, limit int) (string, int, error) {
- file, err := os.Open(filePath)
- if err != nil {
- return "", 0, err
- }
- defer file.Close()
- lineCount := 0
- scanner := NewLineScanner(file)
- if offset > 0 {
- for lineCount < offset && scanner.Scan() {
- lineCount++
- }
- if err = scanner.Err(); err != nil {
- return "", 0, err
- }
- }
- if offset == 0 {
- _, err = file.Seek(0, io.SeekStart)
- if err != nil {
- return "", 0, err
- }
- }
- var lines []string
- lineCount = offset
- for scanner.Scan() && len(lines) < limit {
- lineCount++
- lineText := scanner.Text()
- if len(lineText) > MaxLineLength {
- lineText = lineText[:MaxLineLength] + "..."
- }
- lines = append(lines, lineText)
- }
- // Continue scanning to get total line count
- for scanner.Scan() {
- lineCount++
- }
- if err := scanner.Err(); err != nil {
- return "", 0, err
- }
- return strings.Join(lines, "\n"), lineCount, nil
- }
- func isImageFile(filePath string) (bool, string) {
- ext := strings.ToLower(filepath.Ext(filePath))
- switch ext {
- case ".jpg", ".jpeg":
- return true, "JPEG"
- case ".png":
- return true, "PNG"
- case ".gif":
- return true, "GIF"
- case ".bmp":
- return true, "BMP"
- case ".svg":
- return true, "SVG"
- case ".webp":
- return true, "WebP"
- default:
- return false, ""
- }
- }
- type LineScanner struct {
- scanner *bufio.Scanner
- }
- func NewLineScanner(r io.Reader) *LineScanner {
- return &LineScanner{
- scanner: bufio.NewScanner(r),
- }
- }
- func (s *LineScanner) Scan() bool {
- return s.scanner.Scan()
- }
- func (s *LineScanner) Text() string {
- return s.scanner.Text()
- }
- func (s *LineScanner) Err() error {
- return s.scanner.Err()
- }
|