v0.38.0 58133ea

read.go RawHistoryFolder

1// Package tools implements zut's built-in tools: read, write, edit, bash,
2// create_worktree, grep, lsp, web search, and autonomous goal updates.
3package tools
4
5import (
6	"bytes"
7	"context"
8	"encoding/json"
9	"fmt"
10	"io"
11	"os"
12	"path/filepath"
13	"strings"
14
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17)
18
19const (
20	maxReadLines = 2000
21	maxReadBytes = 50 * 1024
22)
23
24// ReadTool reads file contents from disk.
25type ReadTool struct {
26	CWD     string
27	Sandbox *Sandbox // when jailed, confines reads to the sandbox root
28}
29
30type readArgs struct {
31	Path   string `json:"path"`
32	Offset int    `json:"offset,omitempty"`
33	Limit  int    `json:"limit,omitempty"`
34}
35
36const readSchema = `{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}`
37
38func (t *ReadTool) Name() string { return "read" }
39func (t *ReadTool) Description() string {
40	return "Read a file. Images (png/jpg/gif/webp) return inline."
41}
42func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
43
44func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
45	var a readArgs
46	if err := json.Unmarshal(raw, &a); err != nil {
47		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
48	}
49	if a.Path == "" {
50		return core.ToolResult{}, fmt.Errorf("path is required")
51	}
52	path := resolvePath(t.CWD, a.Path)
53	if err := t.Sandbox.CheckReadPath(path); err != nil {
54		return core.ToolResult{}, err
55	}
56	// What the model sees in errors: relative to the sandbox root when
57	// jailed, so absolute paths stay out of the context window.
58	shown := t.Sandbox.DisplayPath(path, a.Path)
59
60	info, err := os.Stat(path)
61	if err != nil {
62		return core.ToolResult{}, err
63	}
64	if info.IsDir() {
65		return core.ToolResult{}, fmt.Errorf("%s is a directory", shown)
66	}
67
68	// Image handling.
69	if mime := imageMIME(path); mime != "" {
70		data, err := os.ReadFile(path)
71		if err != nil {
72			return core.ToolResult{}, err
73		}
74		// The extension is only a hint. Files are routinely mislabeled
75		// (a .png that is actually JPEG bytes, a renamed download, an
76		// editor that re-encoded on save). Anthropic and other providers
77		// sniff the real bytes and reject the whole request when the
78		// declared media type disagrees, which would break the session.
79		// Always derive the MIME from the actual content; fall back to
80		// the extension-based guess only when the bytes are unrecognized.
81		if sniffed := sniffImageMIME(data); sniffed != "" {
82			mime = sniffed
83		}
84		return core.ToolResult{
85			Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
86			Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path)}}},
87		}, nil
88	}
89
90	f, err := os.Open(path)
91	if err != nil {
92		return core.ToolResult{}, err
93	}
94	defer f.Close()
95
96	// Read up to maxReadBytes and also line-limit.
97	limited := io.LimitReader(f, int64(maxReadBytes)+1)
98	data, err := io.ReadAll(limited)
99	if err != nil {
100		return core.ToolResult{}, err
101	}
102	truncBytes := len(data) > maxReadBytes
103	if truncBytes {
104		data = data[:maxReadBytes]
105	}
106
107	if looksBinary(data) {
108		return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
109	}
110
111	lines := strings.Split(string(data), "\n")
112	// Trim trailing empty line from final \n.
113	if n := len(lines); n > 0 && lines[n-1] == "" {
114		lines = lines[:n-1]
115	}
116
117	start := 0
118	if a.Offset > 0 {
119		start = a.Offset - 1
120		if start > len(lines) {
121			start = len(lines)
122		}
123	}
124	end := len(lines)
125	if a.Limit > 0 && start+a.Limit < end {
126		end = start + a.Limit
127	}
128	selected := lines[start:end]
129
130	truncLines := false
131	if len(selected) > maxReadLines {
132		selected = selected[:maxReadLines]
133		truncLines = true
134	}
135
136	// Raw file contents go to the model. We deliberately DON'T
137	// prepend line numbers here: they'd inflate the token count by
138	// ~15-20% on typical source files (7 bytes per line, every
139	// line, every time the file gets re-sent as context on later
140	// turns) and the model doesn't need them — edit goes through
141	// exact-match text replacement, not line ranges.
142	//
143	// The TUI renders its own gutter using the start offset stored
144	// in Details, so the on-screen view still looks like cat -n.
145	var sb strings.Builder
146	for _, line := range selected {
147		sb.WriteString(line)
148		sb.WriteByte('\n')
149	}
150	if truncLines || truncBytes {
151		sb.WriteString("\n")
152	}
153	if truncLines {
154		fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxReadLines)
155	}
156	if truncBytes {
157		fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxReadBytes)
158	}
159
160	variant := fmt.Sprintf("%d:%d", a.Offset, a.Limit)
161	return core.ToolResult{
162		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
163		Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path), Variant: variant}}},
164		Details: map[string]any{
165			"path":            path,
166			"start_line":      start + 1, // 1-indexed; TUI draws the gutter
167			"lines_truncated": truncLines,
168			"bytes_truncated": truncBytes,
169			"total_lines":     len(lines),
170		},
171	}, nil
172}
173
174func canonicalResourcePath(path string) string {
175	absolute, err := filepath.Abs(path)
176	if err == nil {
177		return filepath.Clean(absolute)
178	}
179	return filepath.Clean(path)
180}
181
182func resolvePath(cwd, p string) string {
183	if filepath.IsAbs(p) {
184		return p
185	}
186	if cwd == "" {
187		cwd, _ = os.Getwd()
188	}
189	return filepath.Join(cwd, p)
190}
191
192func imageMIME(path string) string {
193	switch strings.ToLower(filepath.Ext(path)) {
194	case ".png":
195		return "image/png"
196	case ".jpg", ".jpeg":
197		return "image/jpeg"
198	case ".gif":
199		return "image/gif"
200	case ".webp":
201		return "image/webp"
202	}
203	return ""
204}
205
206// sniffImageMIME inspects the leading bytes of an image file and
207// returns the real media type, independent of the file's extension.
208// Providers validate the declared media type against the actual bytes
209// and 400 the whole request on a mismatch, so the extension can never
210// be trusted. Returns "" when the format is not one zut ships images
211// for, leaving the caller's extension-based guess in place.
212func sniffImageMIME(data []byte) string {
213	switch {
214	case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
215		return "image/png"
216	case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
217		return "image/jpeg"
218	case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
219		return "image/gif"
220	case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
221		return "image/webp"
222	}
223	return ""
224}
225
226// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
227func looksBinary(b []byte) bool {
228	n := len(b)
229	if n > 8192 {
230		n = 8192
231	}
232	for i := 0; i < n; i++ {
233		if b[i] == 0 {
234			return true
235		}
236	}
237	return false
238}

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