v0.2.39 8cd6818

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/packages/core"
14	"github.com/patriceckhart/zot/packages/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 jailed, 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 = `{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}`
35
36func (t *ReadTool) Name() string { return "read" }
37func (t *ReadTool) Description() string {
38	return "Read a file. Images (png/jpg/gif/webp) return inline."
39}
40func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
41
42func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
43	var a readArgs
44	if err := json.Unmarshal(raw, &a); err != nil {
45		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
46	}
47	if a.Path == "" {
48		return core.ToolResult{}, fmt.Errorf("path is required")
49	}
50	path := resolvePath(t.CWD, a.Path)
51	if err := t.Sandbox.CheckPath(path); err != nil {
52		return core.ToolResult{}, err
53	}
54	// What the model sees in errors: relative to the sandbox root when
55	// jailed, so absolute paths stay out of the context window.
56	shown := t.Sandbox.DisplayPath(path, a.Path)
57
58	info, err := os.Stat(path)
59	if err != nil {
60		return core.ToolResult{}, err
61	}
62	if info.IsDir() {
63		return core.ToolResult{}, fmt.Errorf("%s is a directory", shown)
64	}
65
66	// Image handling.
67	if mime := imageMIME(path); mime != "" {
68		data, err := os.ReadFile(path)
69		if err != nil {
70			return core.ToolResult{}, err
71		}
72		return core.ToolResult{
73			Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
74		}, nil
75	}
76
77	f, err := os.Open(path)
78	if err != nil {
79		return core.ToolResult{}, err
80	}
81	defer f.Close()
82
83	// Read up to maxReadBytes and also line-limit.
84	limited := io.LimitReader(f, int64(maxReadBytes)+1)
85	data, err := io.ReadAll(limited)
86	if err != nil {
87		return core.ToolResult{}, err
88	}
89	truncBytes := len(data) > maxReadBytes
90	if truncBytes {
91		data = data[:maxReadBytes]
92	}
93
94	if looksBinary(data) {
95		return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
96	}
97
98	lines := strings.Split(string(data), "\n")
99	// Trim trailing empty line from final \n.
100	if n := len(lines); n > 0 && lines[n-1] == "" {
101		lines = lines[:n-1]
102	}
103
104	start := 0
105	if a.Offset > 0 {
106		start = a.Offset - 1
107		if start > len(lines) {
108			start = len(lines)
109		}
110	}
111	end := len(lines)
112	if a.Limit > 0 && start+a.Limit < end {
113		end = start + a.Limit
114	}
115	selected := lines[start:end]
116
117	truncLines := false
118	if len(selected) > maxReadLines {
119		selected = selected[:maxReadLines]
120		truncLines = true
121	}
122
123	// Raw file contents go to the model. We deliberately DON'T
124	// prepend line numbers here: they'd inflate the token count by
125	// ~15-20% on typical source files (7 bytes per line, every
126	// line, every time the file gets re-sent as context on later
127	// turns) and the model doesn't need them — edit goes through
128	// exact-match text replacement, not line ranges.
129	//
130	// The TUI renders its own gutter using the start offset stored
131	// in Details, so the on-screen view still looks like cat -n.
132	var sb strings.Builder
133	for _, line := range selected {
134		sb.WriteString(line)
135		sb.WriteByte('\n')
136	}
137	if truncLines || truncBytes {
138		sb.WriteString("\n")
139	}
140	if truncLines {
141		sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines))
142	}
143	if truncBytes {
144		sb.WriteString(fmt.Sprintf("... [truncated at %d bytes]\n", maxReadBytes))
145	}
146
147	return core.ToolResult{
148		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
149		Details: map[string]any{
150			"path":            path,
151			"start_line":      start + 1, // 1-indexed; TUI draws the gutter
152			"lines_truncated": truncLines,
153			"bytes_truncated": truncBytes,
154			"total_lines":     len(lines),
155		},
156	}, nil
157}
158
159func resolvePath(cwd, p string) string {
160	if filepath.IsAbs(p) {
161		return p
162	}
163	if cwd == "" {
164		cwd, _ = os.Getwd()
165	}
166	return filepath.Join(cwd, p)
167}
168
169func imageMIME(path string) string {
170	switch strings.ToLower(filepath.Ext(path)) {
171	case ".png":
172		return "image/png"
173	case ".jpg", ".jpeg":
174		return "image/jpeg"
175	case ".gif":
176		return "image/gif"
177	case ".webp":
178		return "image/webp"
179	}
180	return ""
181}
182
183// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
184func looksBinary(b []byte) bool {
185	n := len(b)
186	if n > 8192 {
187		n = 8192
188	}
189	for i := 0; i < n; i++ {
190		if b[i] == 0 {
191			return true
192		}
193	}
194	return false
195}

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