v0.29.1 d92a60f

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

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