v0.0.21 4dde650

read.go RawHistoryFolder

1// Package tools implements zot's built-in tools: read, write, edit, bash.
2package tools
3
4import (
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"os"
10	"path/filepath"
11	"strings"
12
13	"github.com/patriceckhart/zot/internal/core"
14	"github.com/patriceckhart/zot/internal/provider"
15)
16
17const (
18	maxReadLines = 2000
19	maxReadBytes = 50 * 1024
20)
21
22// ReadTool reads file contents from disk.
23type ReadTool struct {
24	CWD     string
25	Sandbox *Sandbox // when locked, confines reads to the sandbox root
26}
27
28type readArgs struct {
29	Path   string `json:"path"`
30	Offset int    `json:"offset,omitempty"`
31	Limit  int    `json:"limit,omitempty"`
32}
33
34const readSchema = `{
35  "type":"object",
36  "properties":{
37    "path":{"type":"string","description":"Path to the file to read (relative or absolute)"},
38    "offset":{"type":"integer","description":"Line number to start reading from (1-indexed)"},
39    "limit":{"type":"integer","description":"Maximum number of lines to read"}
40  },
41  "required":["path"],
42  "additionalProperties":false
43}`
44
45func (t *ReadTool) Name() string { return "read" }
46func (t *ReadTool) Description() string {
47	return "Read the contents of a text file or an image (png/jpg/jpeg/gif/webp). Large files are truncated."
48}
49func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
50
51func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
52	var a readArgs
53	if err := json.Unmarshal(raw, &a); err != nil {
54		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
55	}
56	if a.Path == "" {
57		return core.ToolResult{}, fmt.Errorf("path is required")
58	}
59	path := resolvePath(t.CWD, a.Path)
60	if err := t.Sandbox.CheckPath(path); err != nil {
61		return core.ToolResult{}, err
62	}
63
64	info, err := os.Stat(path)
65	if err != nil {
66		return core.ToolResult{}, err
67	}
68	if info.IsDir() {
69		return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
70	}
71
72	// Image handling.
73	if mime := imageMIME(path); mime != "" {
74		data, err := os.ReadFile(path)
75		if err != nil {
76			return core.ToolResult{}, err
77		}
78		return core.ToolResult{
79			Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
80		}, nil
81	}
82
83	f, err := os.Open(path)
84	if err != nil {
85		return core.ToolResult{}, err
86	}
87	defer f.Close()
88
89	// Read up to maxReadBytes and also line-limit.
90	limited := io.LimitReader(f, int64(maxReadBytes)+1)
91	data, err := io.ReadAll(limited)
92	if err != nil {
93		return core.ToolResult{}, err
94	}
95	truncBytes := len(data) > maxReadBytes
96	if truncBytes {
97		data = data[:maxReadBytes]
98	}
99
100	if looksBinary(data) {
101		return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
102	}
103
104	lines := strings.Split(string(data), "\n")
105	// Trim trailing empty line from final \n.
106	if n := len(lines); n > 0 && lines[n-1] == "" {
107		lines = lines[:n-1]
108	}
109
110	start := 0
111	if a.Offset > 0 {
112		start = a.Offset - 1
113		if start > len(lines) {
114			start = len(lines)
115		}
116	}
117	end := len(lines)
118	if a.Limit > 0 && start+a.Limit < end {
119		end = start + a.Limit
120	}
121	selected := lines[start:end]
122
123	truncLines := false
124	if len(selected) > maxReadLines {
125		selected = selected[:maxReadLines]
126		truncLines = true
127	}
128
129	// Render with 1-indexed line numbers, cat -n style.
130	var sb strings.Builder
131	for i, line := range selected {
132		fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line)
133	}
134	if truncLines {
135		sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines))
136	}
137	if truncBytes {
138		sb.WriteString(fmt.Sprintf("... [truncated at %d bytes]\n", maxReadBytes))
139	}
140
141	return core.ToolResult{
142		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
143		Details: map[string]any{
144			"path":            path,
145			"lines_truncated": truncLines,
146			"bytes_truncated": truncBytes,
147			"total_lines":     len(lines),
148		},
149	}, nil
150}
151
152func resolvePath(cwd, p string) string {
153	if filepath.IsAbs(p) {
154		return p
155	}
156	if cwd == "" {
157		cwd, _ = os.Getwd()
158	}
159	return filepath.Join(cwd, p)
160}
161
162func imageMIME(path string) string {
163	switch strings.ToLower(filepath.Ext(path)) {
164	case ".png":
165		return "image/png"
166	case ".jpg", ".jpeg":
167		return "image/jpeg"
168	case ".gif":
169		return "image/gif"
170	case ".webp":
171		return "image/webp"
172	}
173	return ""
174}
175
176// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
177func looksBinary(b []byte) bool {
178	n := len(b)
179	if n > 8192 {
180		n = 8192
181	}
182	for i := 0; i < n; i++ {
183		if b[i] == 0 {
184			return true
185		}
186	}
187	return false
188}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close