v0.15.0 6b178f8

context_budget.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"time"
6	"unicode/utf8"
7
8	"github.com/bnema/zut/packages/provider"
9)
10
11const (
12	maxToolResultTextBytes      = 32 * 1024
13	maxToolResultTotalTextBytes = 128 * 1024
14	toolResultOmissionMarker    = "[tool result content omitted]"
15)
16
17// projectToolResultMessages returns a copied provider-input view of msgs.
18// Text inside each tool result is bounded independently and then against the
19// total budget, with newer results receiving the remaining budget first.
20// Tool-result blocks and all non-text content remain in the projection so
21// tool-call/result pairing stays valid.
22func projectToolResultMessages(msgs []provider.Message) []provider.Message {
23	projected := copyToolResultMessages(msgs)
24	remaining := maxToolResultTotalTextBytes
25
26	for i := len(projected) - 1; i >= 0; i-- {
27		for j := len(projected[i].Content) - 1; j >= 0; j-- {
28			result, ok := projected[i].Content[j].(provider.ToolResultBlock)
29			if !ok {
30				continue
31			}
32
33			timingText := ""
34			if result.Timing != nil {
35				candidate := formatToolTiming(result.Timing)
36				if len(candidate) <= remaining {
37					timingText = candidate
38					remaining -= len(candidate)
39				}
40			}
41
42			textBytes := toolResultTextBytes(result.Content)
43			if textBytes > 0 {
44				limit := maxToolResultTextBytes
45				if remaining < limit {
46					limit = remaining
47				}
48				var retained int
49				result.Content, retained = projectToolResultContent(result.Content, limit)
50				remaining -= retained
51			}
52			if timingText != "" {
53				result.Content = append(result.Content, provider.TextBlock{Text: timingText})
54			}
55			projected[i].Content[j] = result
56		}
57	}
58
59	return projected
60}
61
62// projectProviderMessages creates the provider-only transcript view. The
63// timestamp and timing annotations are deliberately added to this copy so
64// the visible transcript and its persisted historical content remain intact.
65func projectProviderMessages(msgs []provider.Message) []provider.Message {
66	projected := projectToolResultMessages(msgs)
67	for i, message := range projected {
68		if message.Role != provider.RoleUser || message.Time.IsZero() {
69			continue
70		}
71		content := make([]provider.Content, 0, len(message.Content)+1)
72		content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
73		content = append(content, message.Content...)
74		projected[i].Content = content
75	}
76	return projected
77}
78
79func formatToolTiming(timing *provider.ToolTiming) string {
80	return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
81		timing.StartedAt.Format(time.RFC3339),
82		timing.CompletedAt.Format(time.RFC3339),
83		timing.Duration,
84	)
85}
86
87// copyToolResultMessages copies the slices that the projection can replace.
88// Content blocks are values, so sharing their immutable fields is safe; the
89// nested content slice of every ToolResultBlock is copied before projection.
90func copyToolResultMessages(msgs []provider.Message) []provider.Message {
91	if msgs == nil {
92		return nil
93	}
94	out := make([]provider.Message, len(msgs))
95	for i, message := range msgs {
96		out[i] = message
97		if message.Content != nil {
98			out[i].Content = append([]provider.Content(nil), message.Content...)
99		}
100		if message.AddedToolNames != nil {
101			out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
102		}
103		if message.Meta != nil {
104			out[i].Meta = make(map[string]string, len(message.Meta))
105			for key, value := range message.Meta {
106				out[i].Meta[key] = value
107			}
108		}
109		for j, content := range out[i].Content {
110			if result, ok := content.(provider.ToolResultBlock); ok {
111				result.Content = append([]provider.Content(nil), result.Content...)
112				out[i].Content[j] = result
113			}
114		}
115	}
116	return out
117}
118
119func toolResultTextBytes(content []provider.Content) int {
120	total := 0
121	for _, block := range content {
122		if text, ok := block.(provider.TextBlock); ok {
123			total += len(text.Text)
124		}
125	}
126	return total
127}
128
129// projectToolResultContent projects one result's textual content to limit
130// bytes. Every truncated result receives an omission marker, even after the
131// aggregate content budget is exhausted. The returned count excludes that
132// marker so the budget covers retained tool output rather than its notices.
133// Non-text blocks are retained in their original order.
134func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
135	textBytes := toolResultTextBytes(content)
136	if textBytes <= limit {
137		return append([]provider.Content(nil), content...), textBytes
138	}
139
140	out := make([]provider.Content, 0, len(content)+1)
141	prefixRemaining := limit
142	prefixUsed := 0
143	truncated := false
144	for _, block := range content {
145		text, ok := block.(provider.TextBlock)
146		if !ok {
147			out = append(out, block)
148			continue
149		}
150		if truncated {
151			continue
152		}
153		if len(text.Text) <= prefixRemaining {
154			out = append(out, text)
155			prefixRemaining -= len(text.Text)
156			prefixUsed += len(text.Text)
157			continue
158		}
159
160		text.Text = utf8Prefix(text.Text, prefixRemaining)
161		if text.Text != "" {
162			out = append(out, text)
163		}
164		prefixUsed += len(text.Text)
165		truncated = true
166	}
167	out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
168	return out, prefixUsed
169}
170
171func utf8Prefix(text string, limit int) string {
172	if limit <= 0 {
173		return ""
174	}
175	if limit >= len(text) {
176		return text
177	}
178	for limit > 0 && !utf8.RuneStart(text[limit]) {
179		limit--
180	}
181	return text[:limit]
182}

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