v0.11.3 6c26357

view.go RawHistoryFolder

1package tui
2
3import (
4	"encoding/json"
5	"fmt"
6	"os"
7	"regexp"
8	"strconv"
9	"strings"
10	"unicode/utf8"
11
12	"github.com/bnema/zut/packages/provider"
13	"github.com/mattn/go-runewidth"
14)
15
16// expandTabs replaces tab characters with 4 spaces so code from
17// tab-indented languages (Go, Makefiles, etc.) renders at a
18// consistent width instead of the terminal's default 8-column tabs.
19func expandTabs(s string) string {
20	return strings.ReplaceAll(s, "\t", "    ")
21}
22
23// sanitizeUserBubbleLine prepares a single user-bubble row for safe
24// rendering. Pasted content from another terminal can contain
25// embedded ANSI escape sequences, control bytes, and tabs that
26// either reset the bubble's background colour or move the cursor in
27// ways that break the bubble's painted column.
28func sanitizeUserBubbleLine(s string) string {
29	if s == "" {
30		return s
31	}
32	s = expandTabs(s)
33	var b strings.Builder
34	b.Grow(len(s))
35	for i := 0; i < len(s); {
36		c := s[i]
37		if c == 0x1b { // ESC: drop CSI/OSC/DCS and simple escapes.
38			i = skipEscapeSequence(s, i)
39			continue
40		}
41		if c == '\r' || c == '\b' || c == 0x07 {
42			i++
43			continue
44		}
45		if c < 0x20 || c == 0x7f {
46			i++
47			continue
48		}
49		b.WriteByte(c)
50		i++
51	}
52	return b.String()
53}
54
55// pathFromToolArgs returns the "path" argument from a tool_call's
56// JSON arguments, or "" if the args aren't a JSON object or don't
57// include one. Used to pick a syntax language for rendering the
58// corresponding tool_result.
59func pathFromToolArgs(raw json.RawMessage) string {
60	if len(raw) == 0 {
61		return ""
62	}
63	var m map[string]any
64	if err := json.Unmarshal(raw, &m); err != nil {
65		return ""
66	}
67	for _, k := range []string{"path", "file_path"} {
68		if s, ok := m[k].(string); ok && s != "" {
69			return s
70		}
71	}
72	return ""
73}
74
75// offsetFromToolArgs returns the read tool's 1-indexed `offset`
76// arg (the first line of the slice the tool was asked to return),
77// or 0 when the call didn't specify one. Used by the tui to draw
78// the line-number gutter aligned to the right starting row, even
79// though the tool's text content itself no longer carries line
80// numbers.
81func offsetFromToolArgs(raw json.RawMessage) int {
82	if len(raw) == 0 {
83		return 0
84	}
85	var m map[string]any
86	if err := json.Unmarshal(raw, &m); err != nil {
87		return 0
88	}
89	switch v := m["offset"].(type) {
90	case float64:
91		return int(v)
92	case int:
93		return v
94	}
95	return 0
96}
97
98// osUserHomeDir is aliased so the test file can swap it.
99var osUserHomeDir = os.UserHomeDir
100
101// View turns a transcript + live state into a slice of styled lines,
102// already wrapped to width.
103type View struct {
104	Theme      Theme
105	ImageProto ImageProtocol // how to render inline images in this terminal
106	Messages   []provider.Message
107	// MessagesRevision is supplied by the owning transcript when available.
108	// Append-only revisions let Build reuse prior message rows without
109	// hashing every existing payload. Zero keeps the standalone hash cache
110	// behaviour for callers that do not have a revision source.
111	MessagesRevision uint64
112	// RenderCacheRevision changes whenever a visual rendering dependency
113	// changes. Interactive mode includes it in its stable transcript key so
114	// the outer cache cannot bypass View's own theme/layout cache checks.
115	RenderCacheRevision uint64
116	// toolPaths maps tool_use_id to the "path" argument of the call, if
117	// any, so tool_result rendering can pick the right syntax language.
118	// Rebuilt on each Build().
119	toolPaths map[string]string
120	// toolStartLines maps tool_use_id to the 1-indexed first line
121	// number of a `read` result, pulled from the call's offset arg.
122	// Used by renderNumberedFile to draw a line-number gutter over
123	// raw (unnumbered) file content the model receives. Rebuilt on
124	// each Build().
125	toolStartLines map[string]int
126	// toolCallLabels maps tool_use_id to the box label (tool name +
127	// short args) for the call, so a tool_result message can render
128	// the box top edge without having to look back at the assistant
129	// message that originated the call. Rebuilt on each Build().
130	toolCallLabels  map[string]string
131	Streaming       string // current assistant text delta
132	StreamingActive bool
133	ToolCalls       []ToolCallView // tool calls in flight or completed
134
135	// Startup resource fields list host-loaded inputs before the transcript
136	// without creating provider messages or persisted entries.
137	StartupAgentName      string
138	StartupContextPaths   []string
139	StartupExtensionNames []string
140	StartupSkillNames     []string
141	StatusLine            string
142	Err                   string
143
144	// liveBodyHigh tracks the tallest live-preview body height seen
145	// per tool-call id, so a streaming edit/write/bash box never
146	// shrinks mid-stream. Keyed by ToolCallView.ID. Entries are only
147	// consulted while a call has no Result; interactive mode prunes
148	// the map when a turn ends.
149	liveBodyHigh      map[string]int
150	liveBodyExpandAll bool
151
152	// FlatTools renders tool calls without the bordered panel: a quiet
153	// header line per call plus indented, frameless output. The
154	// truncation/expand behaviour and theme colors are unchanged.
155	// False (the default) keeps the bordered box.
156	FlatTools bool
157
158	// CompactUser renders sent user messages as a single quiet gutter
159	// line per wrapped row instead of a tinted bubble with a blank
160	// padding row above and below. False (the default) keeps the
161	// padded, background-tinted bubble.
162	CompactUser bool
163
164	// CompactMode reduces visual chrome in the transcript. Tool calls
165	// render with a flat header and no bordered panel, and sent user
166	// messages render without padded background bubbles. False keeps
167	// the current spacious rendering.
168	CompactMode bool
169
170	// ExpandAll forces every long tool result to render in full.
171	// Toggled from the tui by ctrl+o. When false, results longer than
172	// ToolCollapseLines collapse to ToolCollapsePreview lines plus a
173	// "... (N more lines, M total, ctrl+o to expand)" footer.
174	ExpandAll bool
175
176	// renderCache holds the per-message rendered line slices so Build
177	// doesn't re-markdown every message on every frame. Keyed by a
178	// struct of (content hash, width, expandAll) — any of those
179	// changing invalidates the entry. Messages are append-only after
180	// they finalise so keeping the cache across turns is safe. The
181	// revision-indexed cache additionally carries visual configuration
182	// dependencies so resize/theme/compact changes cannot go stale.
183	//
184	// Streaming/in-flight work is cached per tool call. Interactive mode
185	// advances ToolCallView.Revision for each visible update, so unchanged
186	// tool results do not need to hash their payload on every frame.
187	renderCache          map[msgCacheKey][]string
188	liveRenderCache      map[liveToolCacheKey][]string
189	messageCache         []messageRenderCacheEntry
190	messageCacheStart    int
191	messageCacheRevision uint64
192	messageCacheLen      int
193	messageCachePrefix   uint64
194	messageCachePrefixOK bool
195	toolPathRevision     uint64
196
197	// TailLimit caps how many messages from the END of Messages are
198	// rendered. Messages older than the limit emit a zero-row slice
199	// so first paint after a session resume doesn't pay the markdown
200	// / chroma cost for the entire transcript at once. 0 means no
201	// limit (render everything, the historical behaviour). Interactive
202	// raises the cap when the user scrolls past the top of the
203	// already-rendered tail.
204	TailLimit int
205}
206
207// msgVisualKey identifies the visual configuration that affects a cached
208// message render. Keep this shared by both cache implementations so their
209// invalidation rules cannot drift apart.
210type msgVisualKey struct {
211	width       int
212	expandAll   bool
213	turnOpen    bool
214	flatTools   bool
215	compactMode bool
216	compactUser bool
217	imageProto  ImageProtocol
218	theme       uint64
219}
220
221// msgCacheKey identifies a cached message render. hash is a 64-bit
222// FNV-1a of the message's content, which is cheap to compute and
223// unambiguous enough for the cache (collisions produce a stale frame,
224// not wrong data, and we recompute on invalidation anyway).
225type msgCacheKey struct {
226	hash   uint64
227	visual msgVisualKey
228}
229
230type messageRenderCacheEntry struct {
231	visual msgVisualKey
232	lines  []string
233	valid  bool
234}
235
236type liveToolCacheKey struct {
237	revision   uint64
238	content    uint64
239	width      int
240	expandAll  bool
241	flatTools  bool
242	compact    bool
243	imageProto ImageProtocol
244	theme      uint64
245}
246
247// InvalidateRenderCache drops all cached message renders. The tui
248// calls this when the transcript is replaced wholesale (/compact,
249// /clear, session swap) since messages can be replaced in place and
250// a content-hash miss alone doesn't reclaim the old entries.
251func (v *View) InvalidateRenderCache() {
252	v.RenderCacheRevision++
253	v.renderCache = nil
254	v.liveRenderCache = nil
255	v.messageCache = nil
256	v.messageCacheStart = 0
257	v.messageCacheRevision = 0
258	v.messageCacheLen = 0
259	v.messageCachePrefix = 0
260	v.messageCachePrefixOK = false
261	v.toolPathRevision = 0
262}
263
264// CloneForRender returns an immutable render snapshot. Its mutable cache
265// maps are copied so a renderer owner can build outside the interactive
266// state mutex without racing the next snapshot.
267func (v *View) CloneForRender() *View {
268	clone := *v
269	clone.Messages = append([]provider.Message(nil), v.Messages...)
270	clone.ToolCalls = append([]ToolCallView(nil), v.ToolCalls...)
271	clone.StartupContextPaths = append([]string(nil), v.StartupContextPaths...)
272	clone.StartupExtensionNames = append([]string(nil), v.StartupExtensionNames...)
273	clone.StartupSkillNames = append([]string(nil), v.StartupSkillNames...)
274	clone.toolPaths = cloneStringMap(v.toolPaths)
275	clone.toolStartLines = cloneIntMap(v.toolStartLines)
276	clone.toolCallLabels = cloneStringMap(v.toolCallLabels)
277	clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh)
278	if v.renderCache != nil {
279		clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache))
280		for key, lines := range v.renderCache {
281			clone.renderCache[key] = lines
282		}
283	}
284	if v.liveRenderCache != nil {
285		clone.liveRenderCache = make(map[liveToolCacheKey][]string, len(v.liveRenderCache))
286		for key, lines := range v.liveRenderCache {
287			clone.liveRenderCache[key] = lines
288		}
289	}
290	clone.messageCache = append([]messageRenderCacheEntry(nil), v.messageCache...)
291	return &clone
292}
293
294func cloneStringMap(src map[string]string) map[string]string {
295	if src == nil {
296		return nil
297	}
298	dst := make(map[string]string, len(src))
299	for key, value := range src {
300		dst[key] = value
301	}
302	return dst
303}
304
305func cloneIntMap(src map[string]int) map[string]int {
306	if src == nil {
307		return nil
308	}
309	dst := make(map[string]int, len(src))
310	for key, value := range src {
311		dst[key] = value
312	}
313	return dst
314}
315
316// AdoptRenderCacheFrom transfers caches produced from a render snapshot
317// back to the owning view. Callers must ensure the transcript revision is
318// still current before adopting them.
319func (v *View) AdoptRenderCacheFrom(snapshot *View) {
320	v.renderCache = snapshot.renderCache
321	v.liveRenderCache = snapshot.liveRenderCache
322	v.messageCache = snapshot.messageCache
323	v.messageCacheStart = snapshot.messageCacheStart
324	v.messageCacheRevision = snapshot.messageCacheRevision
325	v.messageCacheLen = snapshot.messageCacheLen
326	v.messageCachePrefix = snapshot.messageCachePrefix
327	v.messageCachePrefixOK = snapshot.messageCachePrefixOK
328	v.toolPaths = snapshot.toolPaths
329	v.toolStartLines = snapshot.toolStartLines
330	v.toolCallLabels = snapshot.toolCallLabels
331	v.toolPathRevision = snapshot.toolPathRevision
332}
333
334// ToolCollapsePreview is the number of lines shown before a long tool
335// result is replaced with a "... ctrl+o to expand" footer. Tool
336// results shorter than ToolCollapseLines always render in full.
337const (
338	ToolCollapsePreview = 10
339	ToolCollapseLines   = 12
340)
341
342// ToolCallView is a pending tool invocation plus optional result.
343type ToolCallView struct {
344	ID   string
345	Name string
346	// Revision is advanced by the owning interactive state whenever a
347	// visible field changes. A non-zero revision lets the live render cache
348	// avoid rescanning a large result string on every redraw.
349	Revision uint64
350	Args     string // rendered argument summary
351	Preview  string // side-effect-free result shown before confirmation
352	Result   string // rendered result preview (truncated)
353	Error    bool
354	Done     bool
355
356	// Streaming is true while the model is still typing the tool
357	// call's JSON arguments. The TUI renders a live preview of any
358	// interesting string fields (for `write`, the `content`; for
359	// `bash`, the `command`) so the user can watch the file being
360	// composed. Set to false as soon as EvToolUseEnd arrives.
361	Streaming bool
362
363	// RawJSONBuf is the accumulator of every EvToolUseArgs delta
364	// the stream has delivered for this tool call. Used by the
365	// partial-JSON extractor to peel off the live string value
366	// of one named field on each render.
367	RawJSONBuf string
368
369	// LivePath is the `path` arg extracted as soon as it parses
370	// out of RawJSONBuf. Shown next to the tool name in the header
371	// so the user can see which file is being written to.
372	LivePath string
373}
374
375// MessageAnchor records where a rendered message starts in the chat
376// line slice. Used by /jump so the dialog can scroll the viewport to
377// the row where a turn's user prompt begins.
378type MessageAnchor struct {
379	MessageIdx int // index into v.Messages
380	Row        int // first row of that message in the Build() output
381}
382
383// Build returns the chat log lines for the given width.
384func (v *View) Build(width int) []string {
385	lines, _ := v.BuildWithAnchors(width)
386	return lines
387}
388
389// BuildLive renders only in-flight assistant/tool/error state. Main-screen
390// scrollback renderers can keep these rows outside the immutable transcript
391// so native scrolling stays stable while a turn streams.
392func (v *View) BuildLive(width int) []string {
393	v.prepareLiveBodyHeights()
394	if v.liveRenderCache == nil {
395		v.liveRenderCache = make(map[liveToolCacheKey][]string)
396	}
397	themeKey := toolThemeKey(v.Theme)
398	var out []string
399	if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
400		const indent = "  "
401		inner := assistantBodyWidth(width - len(indent))
402		md := RenderMarkdown(v.Streaming, v.Theme, inner)
403		for _, l := range strings.Split(md, "\n") {
404			for _, w := range wrapANSILine(l, inner) {
405				out = append(out, indent+w)
406			}
407		}
408		// out = append(out, "")
409	}
410	finalised := map[string]bool{}
411	for _, m := range v.Messages {
412		for _, c := range m.Content {
413			if b, ok := c.(provider.ToolResultBlock); ok {
414				finalised[b.CallID] = true
415			}
416		}
417	}
418	insertedToolGap := false
419	for _, tc := range v.ToolCalls {
420		if finalised[tc.ID] {
421			continue
422		}
423		if !insertedToolGap && len(out) > 0 {
424			out = append(out, "")
425		}
426		insertedToolGap = true
427		out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
428	}
429	if v.Err != "" {
430		out = append(out, v.renderErr(width)...)
431		// out = append(out, "")
432	}
433	return out
434}
435
436// renderErr formats v.Err into one or more colored rows, word-wrapped
437// to width so long provider error messages (e.g. a Bedrock 400 with an
438// embedded JSON body) aren't cut off at the terminal edge. The first
439// row carries the "✖ " marker; continuation rows align under the text
440// with a two-cell indent.
441func (v *View) renderErr(width int) []string {
442	const marker = "✖ "
443	const indent = "  "
444	wrapWidth := width - len([]rune(marker))
445	if wrapWidth < 8 {
446		wrapWidth = 8
447	}
448	wrapped := wrapLine(v.Err, wrapWidth, "")
449	if len(wrapped) == 0 {
450		wrapped = []string{""}
451	}
452	out := make([]string, 0, len(wrapped))
453	for idx, line := range wrapped {
454		prefix := marker
455		if idx > 0 {
456			prefix = indent
457		}
458		out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
459	}
460	return out
461}
462
463func (v *View) renderStartupResources(width int) []string {
464	bodyWidth := width - 2
465	if bodyWidth < 8 {
466		bodyWidth = 8
467	}
468	var out []string
469	appendSection := func(label string, items []string) {
470		if len(items) == 0 {
471			return
472		}
473		out = append(out, v.Theme.FGColor(v.Theme.Accent, "["+label+"]"))
474		for _, line := range wrapLine(strings.Join(items, ", "), bodyWidth, "") {
475			out = append(out, v.Theme.FGColor(v.Theme.Muted, "  "+line))
476		}
477		out = append(out, "")
478	}
479	if v.StartupAgentName != "" {
480		appendSection("Agent", []string{v.StartupAgentName})
481	}
482	appendSection("Context", v.StartupContextPaths)
483	appendSection("Extensions", v.StartupExtensionNames)
484	appendSection("Skills", v.StartupSkillNames)
485	return out
486}
487
488// BuildWithAnchors is like Build but additionally reports the first
489// row occupied by each message in v.Messages. Callers that need to
490// scroll to a specific turn (the /jump dialog) use the anchor slice
491// to map a message index back to a row offset.
492func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
493	themeKey := toolThemeKey(v.Theme)
494	if v.MessagesRevision == 0 && v.messageCacheRevision != 0 {
495		v.messageCache = nil
496		v.messageCacheStart = 0
497		v.messageCacheRevision = 0
498		v.messageCacheLen = 0
499		v.messageCachePrefix = 0
500		v.messageCachePrefixOK = false
501	}
502	if v.MessagesRevision != 0 {
503		if v.messageCacheRevision != 0 && v.messageCacheRevision != v.MessagesRevision {
504			verifiedAppend := len(v.Messages) > v.messageCacheLen && v.messageCachePrefixOK &&
505				hashMessagePrefix(v.Messages, v.messageCacheLen) == v.messageCachePrefix
506			if verifiedAppend {
507				for _, m := range v.Messages[v.messageCacheLen:] {
508					v.messageCachePrefix = appendMessageHash(v.messageCachePrefix, hashMessage(m))
509				}
510			} else {
511				v.messageCache = nil
512				v.messageCacheStart = 0
513				v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
514			}
515			v.messageCachePrefixOK = true
516		}
517		if v.messageCacheRevision == 0 {
518			v.messageCachePrefix = hashMessagePrefix(v.Messages, len(v.Messages))
519			v.messageCachePrefixOK = true
520		}
521		v.messageCacheRevision = v.MessagesRevision
522		v.messageCacheLen = len(v.Messages)
523	}
524	if v.MessagesRevision == 0 || v.toolPathRevision != v.MessagesRevision {
525		v.refreshToolPaths()
526		v.toolPathRevision = v.MessagesRevision
527	}
528	if v.renderCache == nil {
529		v.renderCache = make(map[msgCacheKey][]string)
530	}
531	if v.liveRenderCache == nil {
532		v.liveRenderCache = make(map[liveToolCacheKey][]string)
533	}
534	v.prepareLiveBodyHeights()
535	// Drop high-water entries for calls that are gone or finalised so
536	// the reservation never leaks into a later, shorter tool box.
537	if len(v.liveBodyHigh) > 0 {
538		active := make(map[string]bool, len(v.ToolCalls))
539		for _, tc := range v.ToolCalls {
540			if tc.Result == "" {
541				active[tc.ID] = true
542			}
543		}
544		for id := range v.liveBodyHigh {
545			if !active[id] {
546				delete(v.liveBodyHigh, id)
547			}
548		}
549	}
550
551	// Pre-render every message (hits the cache for unchanged ones) so
552	// we can allocate `out` in a single shot with the exact capacity.
553	// Growing via append on a long transcript copies the backing array
554	// log2(N) times; for a 2000-line scrollback that's enough memcpy
555	// to visibly stutter while typing.
556	rendered := make([][]string, len(v.Messages))
557	total := 0
558	// renderFrom is the first index we actually render. Anything
559	// below it emits a zero-row placeholder; the message still gets
560	// an anchor entry so /jump and scroll math keep working, and a
561	// later Build with a higher TailLimit fills the gap.
562	renderFrom := 0
563	if v.TailLimit > 0 && len(v.Messages) > v.TailLimit {
564		renderFrom = len(v.Messages) - v.TailLimit
565	}
566	v.boundMessageCache(renderFrom, len(v.Messages))
567	for idx, m := range v.Messages {
568		if idx < renderFrom {
569			rendered[idx] = nil
570			continue
571		}
572		// A turn is "open" once the assistant has started responding
573		// to the most recent user prompt. Walk back over consecutive
574		// assistant/tool messages: if any non-user message precedes
575		// this one without a user message in between, we're inside
576		// the same turn and should not draw a new "▍ zut" header.
577		turnOpen := false
578		if m.Role == provider.RoleAssistant {
579			for j := idx - 1; j >= 0; j-- {
580				prev := v.Messages[j]
581				// Skip compaction summary messages — they're
582				// rendered as a muted footer line (or hidden
583				// entirely when collapsed) and don't open a turn.
584				if prev.Meta["compaction"] == "true" {
585					continue
586				}
587				if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool {
588					turnOpen = true
589				}
590				break
591			}
592		}
593		lines := v.renderMessageCached(idx, m, width, turnOpen, themeKey)
594		rendered[idx] = lines
595		total += len(lines) + 1 // +1 for the blank separator row
596	}
597
598	out := make([]string, 0, total+16)
599	out = append(out, v.renderStartupResources(width)...)
600	anchors := make([]MessageAnchor, 0, len(v.Messages))
601	for idx := range v.Messages {
602		anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
603		out = append(out, rendered[idx]...)
604		// Skip the inter-message blank for messages that rendered
605		// to nothing. An assistant message whose only content is
606		// ToolCallBlock(s) has no rendered output of its own (each
607		// tool result owns its box now), so emitting a blank for it
608		// would compound with the blank from the next real message
609		// and produce two blank rows between adjacent tool boxes.
610		if len(rendered[idx]) == 0 {
611			continue
612		}
613		out = append(out, "")
614	}
615	// Only render the streaming header/body when there's actual
616	// text to show. An empty streaming block (streamOn=true,
617	// Streaming="") appears when a turn starts with a tool_use
618	// block instead of text — in that case the live tool-call
619	// overlay below is the real content and a naked "zut" bar
620	// above it reads as a stray empty message.
621	if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
622		// Stream the partial assistant text through the same markdown
623		// renderer used for finalised messages so code fences, diffs,
624		// lists, and inline styles look the same while streaming and
625		// don't suddenly reflow when the turn ends. No speaker header
626		// is drawn; the indent matches the finalised assistant body in
627		// renderMessage so the column stays consistent across the
628		// stream/finalise transition. Width is capped so ultra-wide
629		// terminals don't produce edge-to-edge code-fence rules or
630		// unreadably long prose lines.
631		const indent = "  "
632		inner := assistantBodyWidth(width - len(indent))
633		md := RenderMarkdown(v.Streaming, v.Theme, inner)
634		for _, l := range strings.Split(md, "\n") {
635			for _, w := range wrapANSILine(l, inner) {
636				out = append(out, indent+w)
637			}
638		}
639		out = append(out, "")
640	}
641	// Live tool-call overlay: keep the in-flight box visible after
642	// the assistant tool_use is appended, then hide it only when the
643	// matching tool_result reaches the transcript. Assistant tool_use
644	// blocks render no rows of their own, so suppressing the overlay
645	// at that point makes the box disappear while the tool is running.
646	finalised := map[string]bool{}
647	for _, m := range v.Messages {
648		for _, c := range m.Content {
649			if b, ok := c.(provider.ToolResultBlock); ok {
650				finalised[b.CallID] = true
651			}
652		}
653	}
654	for _, tc := range v.ToolCalls {
655		if finalised[tc.ID] {
656			continue
657		}
658		out = append(out, v.renderToolCallCached(tc, width, themeKey)...)
659		out = append(out, "")
660	}
661	if v.Err != "" {
662		out = append(out, v.renderErr(width)...)
663		out = append(out, "")
664	}
665	return out, anchors
666}
667
668// prepareLiveBodyHeights resets live tool height reservations when ctrl+o
669// changes expansion state. Expanded content must not keep a collapsed box
670// padded to its former full height.
671func (v *View) prepareLiveBodyHeights() {
672	if v.liveBodyHigh == nil || v.liveBodyExpandAll != v.ExpandAll {
673		v.liveBodyHigh = make(map[string]int)
674		v.liveBodyExpandAll = v.ExpandAll
675	}
676}
677
678// refreshToolPaths rebuilds the tool_use_id -> path map from the
679// current transcript. Called once per Build() so tool result blocks
680// (which may be cached) can look up their syntax language when they
681// were originally rendered. Walking the transcript here is O(N) but
682// cheap compared to markdown/chroma work it enables.
683func (v *View) refreshToolPaths() {
684	v.toolPaths = map[string]string{}
685	v.toolStartLines = map[string]int{}
686	v.toolCallLabels = map[string]string{}
687	for _, m := range v.Messages {
688		for _, c := range m.Content {
689			if tc, ok := c.(provider.ToolCallBlock); ok {
690				if p := pathFromToolArgs(tc.Arguments); p != "" {
691					v.toolPaths[tc.ID] = p
692				}
693				if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
694					v.toolStartLines[tc.ID] = off
695				}
696				v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments)
697			}
698		}
699	}
700}
701
702func (v *View) boundMessageCache(renderFrom, messageCount int) {
703	if renderFrom < 0 {
704		renderFrom = 0
705	}
706	if renderFrom > messageCount {
707		renderFrom = messageCount
708	}
709	if len(v.messageCache) == 0 {
710		v.messageCacheStart = renderFrom
711		return
712	}
713
714	start := v.messageCacheStart
715	if renderFrom > start {
716		drop := renderFrom - start
717		if drop >= len(v.messageCache) {
718			v.messageCache = nil
719			v.messageCacheStart = renderFrom
720			return
721		}
722		v.messageCache = v.messageCache[drop:]
723		start = renderFrom
724	} else if renderFrom < start {
725		prefix := make([]messageRenderCacheEntry, start-renderFrom)
726		v.messageCache = append(prefix, v.messageCache...)
727		start = renderFrom
728	}
729
730	max := messageCount - renderFrom
731	if len(v.messageCache) > max {
732		v.messageCache = v.messageCache[:max]
733	}
734	v.messageCacheStart = start
735}
736
737// renderMessageCached returns the rendered line slice for m, using the
738// cache if the same (content hash, width, expandAll) combination has
739// been rendered before. The slice returned is shared — callers must
740// not mutate it; Build() only ever appends to its own `out` so the
741// shared slice is safe.
742func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnOpen bool, themeKey uint64) []string {
743	visual := msgVisualKey{
744		width:       width,
745		expandAll:   v.ExpandAll,
746		turnOpen:    turnOpen,
747		flatTools:   v.FlatTools,
748		compactMode: v.CompactMode,
749		compactUser: v.CompactUser,
750		imageProto:  v.ImageProto,
751		theme:       themeKey,
752	}
753	if v.MessagesRevision != 0 {
754		cacheIdx := idx - v.messageCacheStart
755		if cacheIdx >= 0 && cacheIdx < len(v.messageCache) {
756			entry := v.messageCache[cacheIdx]
757			if entry.valid && entry.visual == visual {
758				return entry.lines
759			}
760		}
761		lines := v.renderMessage(m, width, turnOpen)
762		if cacheIdx < 0 {
763			return lines
764		}
765		if cacheIdx >= len(v.messageCache) {
766			v.messageCache = append(v.messageCache, make([]messageRenderCacheEntry, cacheIdx-len(v.messageCache)+1)...)
767		}
768		v.messageCache[cacheIdx] = messageRenderCacheEntry{visual: visual, lines: lines, valid: true}
769		return lines
770	}
771	key := msgCacheKey{hash: hashMessage(m), visual: visual}
772	if v.renderCache != nil {
773		if lines, ok := v.renderCache[key]; ok {
774			return lines
775		}
776	}
777	lines := v.renderMessage(m, width, turnOpen)
778	if v.renderCache != nil {
779		// Bound the cache: 4x the current message count is enough to
780		// survive /compact churn without leaking memory across a very
781		// long session.
782		max := len(v.Messages) * 4
783		if max < 32 {
784			max = 32
785		}
786		if len(v.renderCache) > max {
787			// Drop half the entries. map iteration order gives us a
788			// pseudo-LRU for free.
789			dropped := 0
790			target := len(v.renderCache) / 2
791			for k := range v.renderCache {
792				if dropped >= target {
793					break
794				}
795				delete(v.renderCache, k)
796				dropped++
797			}
798		}
799		v.renderCache[key] = lines
800	}
801	return lines
802}
803
804func appendMessageHash(h, messageHash uint64) uint64 {
805	h = fnv64aWriteByte(h, 0x01)
806	for shift := uint(0); shift < 64; shift += 8 {
807		h = fnv64aWriteByte(h, byte(messageHash>>shift))
808	}
809	return h
810}
811
812func hashMessagePrefix(messages []provider.Message, end int) uint64 {
813	if end > len(messages) {
814		end = len(messages)
815	}
816	h := fnv64aInit
817	for i := 0; i < end; i++ {
818		h = appendMessageHash(h, hashMessage(messages[i]))
819	}
820	return h
821}
822
823// hashMessage returns a 64-bit FNV-1a over the role + content blocks
824// of m. Serialising each block to its salient bytes is enough: two
825// messages with the same role and same content render identically.
826func hashMessage(m provider.Message) uint64 {
827	h := fnv64aInit
828	h = fnv64aWrite(h, []byte(m.Role))
829	h = fnv64aWriteByte(h, 0)
830	for _, c := range m.Content {
831		switch b := c.(type) {
832		case provider.TextBlock:
833			h = fnv64aWriteByte(h, 't')
834			h = fnv64aWrite(h, []byte(b.Text))
835		case provider.ImageBlock:
836			h = fnv64aWriteByte(h, 'i')
837			h = fnv64aWrite(h, []byte(b.MimeType))
838			h = fnv64aWrite(h, b.Data)
839		case provider.ToolCallBlock:
840			h = fnv64aWriteByte(h, 'c')
841			h = fnv64aWrite(h, []byte(b.ID))
842			h = fnv64aWrite(h, []byte(b.Name))
843			h = fnv64aWrite(h, []byte(b.Arguments))
844		case provider.ToolResultBlock:
845			h = fnv64aWriteByte(h, 'r')
846			h = fnv64aWrite(h, []byte(b.CallID))
847			if b.IsError {
848				h = fnv64aWriteByte(h, 'E')
849			}
850			for _, inner := range b.Content {
851				switch ib := inner.(type) {
852				case provider.TextBlock:
853					h = fnv64aWrite(h, []byte(ib.Text))
854				case provider.ImageBlock:
855					h = fnv64aWrite(h, []byte(ib.MimeType))
856					h = fnv64aWrite(h, ib.Data)
857				}
858			}
859		}
860		h = fnv64aWriteByte(h, 0)
861	}
862	return h
863}
864
865// FNV-1a implementation inlined so we don't pay the interface cost of
866// hash.Hash64 on every Build(). The whole point here is speed.
867const (
868	fnv64aInit  uint64 = 0xcbf29ce484222325
869	fnv64aPrime uint64 = 0x100000001b3
870)
871
872// assistantBodyRightPad is the blank gutter kept on the right
873// side of every assistant prose line.
874const assistantBodyRightPad = 2
875
876// assistantBodyWidth returns the usable width for the assistant
877// message body (markdown prose + code fences). Uses the full
878// column width passed in minus assistantBodyRightPad, clamped
879// at 1 so wrap helpers don't divide by zero on absurdly narrow
880// terminals. The right-side padding keeps a small breathing
881// column to the terminal edge that mirrors the left indent.
882func assistantBodyWidth(outer int) int {
883	outer -= assistantBodyRightPad
884	if outer < 1 {
885		return 1
886	}
887	return outer
888}
889
890func fnv64aWriteByte(h uint64, b byte) uint64 {
891	h ^= uint64(b)
892	h *= fnv64aPrime
893	return h
894}
895
896func fnv64aWriteUint64(h, value uint64) uint64 {
897	for shift := uint(0); shift < 64; shift += 8 {
898		h = fnv64aWriteByte(h, byte(value>>shift))
899	}
900	return h
901}
902
903func fnv64aWrite(h uint64, p []byte) uint64 {
904	for _, b := range p {
905		h ^= uint64(b)
906		h *= fnv64aPrime
907	}
908	return h
909}
910
911func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string {
912	var lines []string
913
914	// Compaction summary: render as a single muted line at the end
915	// of the chat instead of as a user message.
916	if m.Meta["compaction"] == "true" {
917		if v.ExpandAll {
918			return v.renderCompactionBlock(m, width)
919		}
920		// Collapsed: skip entirely. The status bar shows the info.
921		return nil
922	}
923
924	switch m.Role {
925	case provider.RoleUser:
926		// User rows: no speaker label. Each line is painted with a
927		// faint bubble background tint (UserBubble) so the user's
928		// turns visually segment the chat without needing a header.
929		// One tinted blank row above and below approximates CSS
930		// padding-top / padding-bottom (terminals can't do fractional
931		// rows, so a full row of bubble-coloured whitespace is the
932		// closest analogue and gives the bubble visible breathing
933		// room).
934		// User rows: a 1-cell "▌" accent bar at column 0 painted on
935		// the bubble bg, so the bar's cell shares the panel tint and
936		// there is no visible gap between bar and panel. (Some terminals
937		// — iTerm2, Apple Terminal — may smear the cell-0 bg into the
938		// window inset to the left; that's a terminal-side rendering
939		// quirk we accept in exchange for a clean bar-to-panel join.)
940		const leftGutter = 0                               // cells of bubble bg between bar and text
941		const rightGutter = 2                              // cells of bubble bg between text and right edge
942		innerWidth := width - 2 - leftGutter - rightGutter // 2 = bar's two cells (▌ + trailing space)
943		if innerWidth < 1 {
944			innerWidth = 1
945		}
946		bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ "))
947		row := func(content string) string {
948			inner := strings.Repeat(" ", leftGutter) + content
949			padded := v.Theme.UserBubble(inner, width-2)
950			return bar + padded
951		}
952		if v.CompactUser || v.CompactMode {
953			// Compact: no tinted bubble background and no padding rows.
954			// Each wrapped row is a quiet "▌ text" gutter line, matching
955			// the flat tool-call header so user turns still segment the
956			// chat without the loud panel.
957			innerWidth = width - 2
958			if innerWidth < 1 {
959				innerWidth = 1
960			}
961			row = func(content string) string {
962				if v.CompactMode {
963					content = v.Theme.FGColor(v.Theme.Muted, content)
964				}
965				return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content
966			}
967		}
968		var bubble []string
969		for _, c := range m.Content {
970			switch b := c.(type) {
971			case provider.TextBlock:
972				for _, l := range strings.Split(b.Text, "\n") {
973					l = sanitizeUserBubbleLine(l)
974					for _, w := range wrapLine(l, innerWidth, "") {
975						bubble = append(bubble, row(w))
976					}
977				}
978			case provider.ImageBlock:
979				bubble = append(bubble, row(fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
980			}
981		}
982		if len(bubble) > 0 {
983			if v.CompactUser || v.CompactMode {
984				// No tinted padding rows in compact mode; the inter-message
985				// blank from Build() already gives the turn breathing room.
986				lines = append(lines, bubble...)
987			} else {
988				lines = append(lines, row(""))
989				lines = append(lines, bubble...)
990				lines = append(lines, row(""))
991			}
992		}
993	case provider.RoleAssistant:
994		// Assistant rows: no speaker label either. Prose still gets a
995		// small left indent so it visually aligns with tool box body
996		// content, but no "zut" header.
997		_ = turnOpen
998		const indent = "  "
999		inner := assistantBodyWidth(width - len(indent))
1000		for _, c := range m.Content {
1001			switch b := c.(type) {
1002			case provider.TextBlock:
1003				md := RenderMarkdown(strings.TrimLeft(b.Text, "\n"), v.Theme, inner)
1004				for _, l := range strings.Split(md, "\n") {
1005					for _, w := range wrapANSILine(l, inner) {
1006						lines = append(lines, indent+w)
1007					}
1008				}
1009			case provider.ToolCallBlock:
1010				// The whole box (top + body + bottom) is rendered by
1011				// the matching tool_result message so a single
1012				// assistant message that batches multiple tool_use
1013				// blocks doesn't produce a stack of unclosed top
1014				// edges. Each tool result owns its own box.
1015				_ = b
1016			}
1017		}
1018	case provider.RoleTool:
1019		for _, c := range m.Content {
1020			if tr, ok := c.(provider.ToolResultBlock); ok {
1021				color := v.Theme.ToolOut
1022				if tr.IsError {
1023					color = v.Theme.Error
1024				}
1025				path := ""
1026				if v.toolPaths != nil {
1027					path = v.toolPaths[tr.CallID]
1028				}
1029				startLine := 1
1030				if v.toolStartLines != nil {
1031					if s := v.toolStartLines[tr.CallID]; s > 0 {
1032						startLine = s
1033					}
1034				}
1035				label := ""
1036				if v.toolCallLabels != nil {
1037					label = v.toolCallLabels[tr.CallID]
1038				}
1039				// Each tool result owns a complete box: top edge with
1040				// the call's label, body rows wrapped in vertical
1041				// edges, bottom edge to close. The label is looked up
1042				// from the matching ToolCallBlock so multiple calls
1043				// in one assistant message render as N adjacent boxes
1044				// instead of stacking unclosed top edges.
1045				if v.FlatTools || v.CompactMode {
1046					if v.CompactMode {
1047						lines = append(lines, compactToolBlank(v.Theme, width))
1048					}
1049					lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1050					if tr.IsError {
1051						lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, "  error"), width, v.CompactMode))
1052					}
1053					for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
1054						_, stripped := parseImageFootprint(line)
1055						lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1056					}
1057					if v.CompactMode {
1058						lines = append(lines, compactToolBlank(v.Theme, width))
1059					}
1060					continue
1061				}
1062				lines = append(lines, toolBoxTop(v.Theme, label, width))
1063				lines = append(lines, toolBoxSide(v.Theme, "", width))
1064				if tr.IsError {
1065					lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, "  error"), width))
1066				}
1067				for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
1068					// Image-footprint rows (the escape row, the blank
1069					// reservation rows beneath it, and the gap row
1070					// before the metadata caption) are tagged with the
1071					// imageFootprintSentinel by renderImageBlock. Strip
1072					// the tag, parse the optional width hint, then wrap
1073					// the row in the usual │ ... │ box edges so the
1074					// frame stays continuous around the image.
1075					imgCells, stripped := parseImageFootprint(line)
1076					if hasImageEscapeLine(stripped) {
1077						lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1078					} else {
1079						lines = append(lines, toolBoxSide(v.Theme, stripped, width))
1080					}
1081				}
1082				lines = append(lines, toolBoxSide(v.Theme, "", width))
1083				lines = append(lines, toolBoxBottom(v.Theme, width))
1084			}
1085		}
1086	}
1087	return lines
1088}
1089
1090// RenderToolCall renders one live or completed tool call using the same
1091// presentation as tool calls in the main transcript. Dialogs with isolated
1092// agent loops use this to avoid maintaining a second tool renderer.
1093func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
1094	if v.liveBodyHigh == nil {
1095		v.liveBodyHigh = make(map[string]int)
1096	}
1097	return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1098}
1099
1100func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1101	if v.liveRenderCache == nil {
1102		v.liveRenderCache = make(map[liveToolCacheKey][]string)
1103	}
1104	key := liveToolCacheKey{
1105		revision:   tc.Revision,
1106		content:    hashLiveTool(tc),
1107		width:      width,
1108		expandAll:  v.ExpandAll,
1109		flatTools:  v.FlatTools,
1110		compact:    v.CompactMode,
1111		imageProto: v.ImageProto,
1112		theme:      themeKey,
1113	}
1114	if lines, ok := v.liveRenderCache[key]; ok {
1115		return lines
1116	}
1117	lines := v.renderToolCall(tc, width)
1118	if len(v.liveRenderCache) > 512 {
1119		for k := range v.liveRenderCache {
1120			delete(v.liveRenderCache, k)
1121			break
1122		}
1123	}
1124	v.liveRenderCache[key] = lines
1125	return lines
1126}
1127
1128func hashLiveTool(tc ToolCallView) uint64 {
1129	if tc.Revision != 0 {
1130		return 0
1131	}
1132	h := fnv64aInit
1133	for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1134		h = fnv64aWrite(h, []byte(s))
1135		h = fnv64aWriteByte(h, 0)
1136	}
1137	if tc.Error {
1138		h = fnv64aWriteByte(h, 'e')
1139	}
1140	if tc.Done {
1141		h = fnv64aWriteByte(h, 'd')
1142	}
1143	if tc.Streaming {
1144		h = fnv64aWriteByte(h, 's')
1145	}
1146	return h
1147}
1148
1149func toolThemeKey(theme Theme) uint64 {
1150	h := fnv64aInit
1151	for _, s := range []string{
1152		theme.SyntaxBaseStyle,
1153		theme.Syntax.Keyword,
1154		theme.Syntax.KeywordConstant,
1155		theme.Syntax.KeywordDeclaration,
1156		theme.Syntax.KeywordNamespace,
1157		theme.Syntax.KeywordReserved,
1158		theme.Syntax.KeywordType,
1159		theme.Syntax.NameBuiltin,
1160		theme.Syntax.NameFunction,
1161		theme.Syntax.NameClass,
1162		theme.Syntax.NameDecorator,
1163		theme.Syntax.LiteralString,
1164		theme.Syntax.LiteralStringEscape,
1165		theme.Syntax.LiteralNumber,
1166		theme.Syntax.Comment,
1167		theme.Syntax.CommentPreproc,
1168		theme.Syntax.Operator,
1169		theme.Syntax.Punctuation,
1170		theme.Syntax.Text,
1171	} {
1172		h = fnv64aWrite(h, []byte(s))
1173		h = fnv64aWriteByte(h, 0)
1174	}
1175	for _, c := range []TerminalColor{
1176		theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1177		theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1178		theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1179		theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1180		theme.Terminal.Background,
1181	} {
1182		h = hashThemeColor(h, c)
1183	}
1184	if theme.Background == nil {
1185		h = fnv64aWriteByte(h, 0)
1186	} else {
1187		h = fnv64aWriteByte(h, 1)
1188		h = hashThemeColor(h, *theme.Background)
1189	}
1190	h = fnv64aWriteByte(h, 0xff)
1191	h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1192	h = fnv64aWriteByte(h, 0xff)
1193	for _, c := range theme.Terminal.Palette {
1194		h = hashThemeColor(h, c)
1195	}
1196	for _, value := range []bool{
1197		theme.Inherited,
1198		theme.Terminal.HasForeground,
1199		theme.Terminal.HasBackground,
1200		theme.Terminal.TrueColor,
1201		theme.Terminal.Light,
1202		theme.Terminal.SchemeKnown,
1203	} {
1204		if value {
1205			h = fnv64aWriteByte(h, 1)
1206		} else {
1207			h = fnv64aWriteByte(h, 0)
1208		}
1209	}
1210	return h
1211}
1212
1213func hashThemeColor(h uint64, c TerminalColor) uint64 {
1214	h = fnv64aWriteByte(h, byte(c.Mode))
1215	h = fnv64aWriteByte(h, byte(c.Index))
1216	h = fnv64aWriteByte(h, byte(c.R))
1217	h = fnv64aWriteByte(h, byte(c.G))
1218	h = fnv64aWriteByte(h, byte(c.B))
1219	return h
1220}
1221
1222func (v *View) renderToolCall(tc ToolCallView, width int) []string {
1223	var lines []string
1224
1225	// Header label. While the call is still streaming, prefer the
1226	// live path extracted from the partial args so the user sees
1227	// the target file as soon as it's known, even before the full
1228	// JSON arrived.
1229	arg := tc.Args
1230	if arg == "" && tc.LivePath != "" {
1231		arg = tc.LivePath
1232	}
1233	label := tc.Name + " " + arg
1234
1235	// No leading blank: Build()'s inter-message separator already
1236	// places one blank row between the previous transcript content
1237	// and the live overlay, matching the spacing the same call has
1238	// once it finalises into a transcript box. Adding another blank
1239	// here would double the gap during streaming and visibly tighten
1240	// when the overlay disappears.
1241
1242	// Live body (write/edit): keep the streamed preview visible until
1243	// a proposed or real tool result arrives. The provider can finish the
1244	// tool_use JSON before zut has executed the tool, so keying this on
1245	// tc.Streaming makes write/edit boxes collapse for a moment between
1246	// EvToolUseEnd and confirmation or EvToolResult.
1247	if tc.Preview == "" && tc.Result == "" {
1248		if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
1249			// Pad the live preview up to the call's high-water height so
1250			// it never shrinks mid-stream (e.g. between edit 1 and the
1251			// start of edit 2). Padding sits inside the box as real
1252			// box-side rows so the surrounding layout stays put. The
1253			// high-water mark is per call id and is dropped the moment a
1254			// Result lands (this branch only runs while Result == "").
1255			high := len(body)
1256			if v.liveBodyHigh != nil {
1257				if prev := v.liveBodyHigh[tc.ID]; prev > high {
1258					high = prev
1259				}
1260				v.liveBodyHigh[tc.ID] = high
1261			}
1262			if v.FlatTools || v.CompactMode {
1263				for len(body) < high {
1264					body = append(body, "")
1265				}
1266				if v.CompactMode {
1267					lines = append(lines, compactToolBlank(v.Theme, width))
1268				}
1269				lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1270				lines = append(lines, body...)
1271				if v.CompactMode {
1272					lines = append(lines, compactToolBlank(v.Theme, width))
1273				}
1274				return lines
1275			}
1276			for len(body) < high {
1277				body = append(body, toolBoxSide(v.Theme, "", width))
1278			}
1279			lines = append(lines, toolBoxTop(v.Theme, label, width))
1280			lines = append(lines, toolBoxSide(v.Theme, "", width))
1281			lines = append(lines, body...)
1282			lines = append(lines, toolBoxSide(v.Theme, "", width))
1283			lines = append(lines, toolBoxBottom(v.Theme, width))
1284			return lines
1285		}
1286	}
1287
1288	bodyText := tc.Result
1289	if bodyText == "" {
1290		bodyText = tc.Preview
1291	}
1292
1293	// Finished tool call with no body: just the labelled top edge
1294	// directly closed by the bottom. Avoids a blank interior row
1295	// for no-output tools.
1296	if bodyText == "" {
1297		if v.FlatTools || v.CompactMode {
1298			if v.CompactMode {
1299				lines = append(lines, compactToolBlank(v.Theme, width))
1300			}
1301			lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1302			if v.CompactMode {
1303				lines = append(lines, compactToolBlank(v.Theme, width))
1304			}
1305			return lines
1306		}
1307		lines = append(lines, toolBoxTop(v.Theme, label, width))
1308		lines = append(lines, toolBoxBottom(v.Theme, width))
1309		return lines
1310	}
1311
1312	// Finished tool call with a result body. Top edge embeds the
1313	// label; body lines go through toolBoxSide so each row gets
1314	// vertical edges; bottom edge closes the box. Blank interior
1315	// rows after the top and before the bottom give the body a bit
1316	// of breathing room from the corners.
1317	color := v.Theme.ToolOut
1318	if tc.Error {
1319		color = v.Theme.Error
1320	}
1321	if v.FlatTools || v.CompactMode {
1322		if v.CompactMode {
1323			lines = append(lines, compactToolBlank(v.Theme, width))
1324		}
1325		lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1326		body := v.renderLiveToolResult(bodyText, flatToolBodyRenderWidth(width), color, tc.LivePath)
1327		for _, l := range v.collapseToolBody(body, false) {
1328			_, stripped := parseImageFootprint(l)
1329			lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1330		}
1331		if v.CompactMode {
1332			lines = append(lines, compactToolBlank(v.Theme, width))
1333		}
1334		return lines
1335	}
1336	lines = append(lines, toolBoxTop(v.Theme, label, width))
1337	lines = append(lines, toolBoxSide(v.Theme, "", width))
1338	body := v.renderLiveToolResult(bodyText, toolBoxBodyRenderWidth(width), color, tc.LivePath)
1339	for _, l := range v.collapseToolBody(body, false) {
1340		imgCells, stripped := parseImageFootprint(l)
1341		if hasImageEscapeLine(stripped) {
1342			lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1343			continue
1344		}
1345		lines = append(lines, toolBoxSide(v.Theme, stripped, width))
1346	}
1347	lines = append(lines, toolBoxSide(v.Theme, "", width))
1348	lines = append(lines, toolBoxBottom(v.Theme, width))
1349	return lines
1350}
1351
1352// renderLiveToolResult uses the transcript's text renderer so every rich
1353// confirmation preview immediately gets the same colors, numbered gutters,
1354// and syntax highlighting as its final tool result.
1355func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1356	full := v.renderToolText(text, width, color, sourcePath, 1)
1357	if v.ExpandAll || len(full) <= ToolCollapseLines {
1358		return full
1359	}
1360	return v.collapseToolBodyWithTotal(full, false, len(full))
1361}
1362
1363// renderLiveToolBody renders the in-flight preview of a streaming
1364// tool call. Supported tools:
1365//
1366//   - write: shows the partial `content` field, syntax-highlighted
1367//     by the target path's language.
1368//   - edit:  shows the partial `newText` of the edit currently being
1369//     streamed, prefixed with a "editing foo.ts (edit 2)" header so
1370//     the user can see which of a multi-edit batch is in progress.
1371//   - bash:  shows the full command as soon as the JSON args are known,
1372//     before stdout/stderr has arrived, so long-running commands are legible.
1373//
1374// Anything else returns nil and only the tool-call header shows.
1375func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
1376	switch tc.Name {
1377	case "write", "Write":
1378		partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
1379		if !ok || partial == "" {
1380			return nil
1381		}
1382		return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
1383	case "edit", "Edit":
1384		partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
1385		if !ok || partial == "" {
1386			return nil
1387		}
1388		// Header line hints which edit is streaming and, when more
1389		// than one has landed, how many the model is doing.
1390		hint := fmt.Sprintf("edit %d (streaming)", idx)
1391		body := []string{"    " + v.Theme.FGColor(v.Theme.Muted, hint), ""}
1392		body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1393		return v.wrapLiveBody(body, width)
1394	case "bash", "Bash":
1395		command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
1396		if !ok || strings.TrimSpace(command) == "" {
1397			return nil
1398		}
1399		return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
1400	}
1401	return nil
1402}
1403
1404func (v *View) renderLiveBashCommand(command string, width int) []string {
1405	inner := toolBoxBodyRenderWidth(width)
1406	if v.FlatTools || v.CompactMode {
1407		inner = flatToolBodyRenderWidth(width)
1408	}
1409	prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
1410	var out []string
1411	for i, line := range strings.Split(command, "\n") {
1412		firstPrefix := "    "
1413		if i == 0 {
1414			firstPrefix += prompt
1415		} else {
1416			firstPrefix += "  "
1417		}
1418		contPrefix := "      "
1419		wrapWidth := inner - visibleWidth(firstPrefix)
1420		if wrapWidth < 10 {
1421			wrapWidth = 10
1422		}
1423		for j, wrapped := range wrapANSILine(v.Theme.FGColor(v.Theme.ToolOut, line), wrapWidth) {
1424			prefix := firstPrefix
1425			if j > 0 {
1426				prefix = contPrefix
1427			}
1428			out = append(out, prefix+wrapped)
1429		}
1430	}
1431	return out
1432}
1433
1434// wrapLiveBody returns the streaming body content as a list of
1435// box-side rows: each line wrapped in │ ... │ with right padding so
1436// the closing edge sits at column width-1. The caller (renderToolCall)
1437// supplies the surrounding top/bottom edges so the live overlay
1438// renders as a closed box matching the finalised transcript form.
1439func (v *View) wrapLiveBody(body []string, width int) []string {
1440	body = v.collapseToolBody(body, false)
1441	out := make([]string, 0, len(body))
1442	if v.FlatTools || v.CompactMode {
1443		for _, l := range body {
1444			_, stripped := parseImageFootprint(l)
1445			out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1446		}
1447		return out
1448	}
1449	for _, l := range body {
1450		imgCells, stripped := parseImageFootprint(l)
1451		if hasImageEscapeLine(stripped) {
1452			out = append(out, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1453			continue
1454		}
1455		out = append(out, toolBoxSide(v.Theme, stripped, width))
1456	}
1457	return out
1458}
1459
1460// toolBoxInnerPad is the number of blank cells kept between a box
1461// edge (┌, │, └) and the content inside it. One cell of breathing
1462// room makes the label read as part of the box rather than fused to
1463// the corner, and gives body lines a tiny gutter from the left side.
1464const toolBoxInnerPad = 1
1465
1466// toolBoxOuterMargin is the number of blank cells kept to the left of
1467// the opening corner and to the right of the closing corner. Aligns
1468// the box's frame with the column where user-bubble text begins, so
1469// the conversation reads as one column instead of having tool boxes
1470// running edge-to-edge while user/assistant rows sit indented.
1471const toolBoxOuterMargin = 2
1472
1473// toolBoxTop renders the labelled top edge of a tool block:
1474//
1475//	┌─  bash xcrun simctl list devices ─────────────────────────────┐
1476//
1477// The label is the tool name + short args (the same string previously
1478// shown on its own row beneath the rule). Padding fills the remainder
1479// of the line so the right corner sits at column width-1, matching the
1480// closing edge.
1481func toolBoxTop(th Theme, label string, width int) string {
1482	label = oneLineToolLabel(label)
1483	w := width - 2*toolBoxOuterMargin
1484	if w < 12 {
1485		w = 12
1486	}
1487	margin := strings.Repeat(" ", toolBoxOuterMargin)
1488	innerPad := strings.Repeat(" ", toolBoxInnerPad)
1489	// "┌─" + innerPad + " " + label + " " + innerPad = used; pad
1490	// with ─ to width-1, then "┐".
1491	prefix := "┌─" + innerPad + " "
1492	suffix := " " + innerPad
1493	used := visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1494	fill := w - used - 1 // -1 for the closing corner
1495	if fill < 0 {
1496		// Label overflows; truncate with an ellipsis so the right
1497		// corner still lands on the right edge. Rare on real
1498		// terminals (the chat column is usually wide enough).
1499		over := -fill
1500		runes := []rune(label)
1501		if over+3 < len(runes) {
1502			label = string(runes[:len(runes)-over-3]) + "..."
1503		} else {
1504			label = "..."
1505		}
1506		used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1507		fill = w - used - 1
1508		if fill < 0 {
1509			fill = 0
1510		}
1511	}
1512	fillStr := strings.Repeat("─", fill)
1513	name, rest := splitToolLabel(label)
1514	return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
1515}
1516
1517func oneLineToolLabel(label string) string {
1518	return strings.Join(strings.Fields(label), " ")
1519}
1520
1521func splitToolLabel(label string) (name, rest string) {
1522	label = strings.TrimLeft(label, " ")
1523	if label == "" {
1524		return "", ""
1525	}
1526	idx := strings.IndexAny(label, " \t")
1527	if idx < 0 {
1528		return label, ""
1529	}
1530	return label[:idx], label[idx:]
1531}
1532
1533// flatToolHeader renders the boxless header line for a tool call:
1534//
1535//	▌ bash xcrun simctl list devices
1536//
1537// It carries the same information as toolBoxTop (tool name + short
1538// args) but drops the frame: a single accent gutter glyph, the tool
1539// name in the foreground color, and the argument summary muted. The
1540// line is left-aligned at the same column the box's opening corner
1541// used (toolBoxOuterMargin) so flat and box renders share a column.
1542func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1543	if compact {
1544		return compactToolHeader(th, label, width)
1545	}
1546	return flatToolHeader(th, label, width)
1547}
1548
1549func flatToolHeader(th Theme, label string, width int) string {
1550	label = oneLineToolLabel(label)
1551	name, rest := splitToolLabel(label)
1552	margin := strings.Repeat(" ", toolBoxOuterMargin)
1553	gutter := th.FGColor(th.Assistant, "▌") + " "
1554	// Budget the visible text to the content width so a very long
1555	// argument summary doesn't run off the right edge; truncate with
1556	// an ellipsis like the box header does.
1557	avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
1558	if avail < 12 {
1559		avail = 12
1560	}
1561	if visibleWidth(name+rest) > avail {
1562		over := visibleWidth(name+rest) - avail
1563		runes := []rune(rest)
1564		if over+3 < len(runes) {
1565			rest = string(runes[:len(runes)-over-3]) + "..."
1566		} else if visibleWidth(name) <= avail {
1567			rest = ""
1568		}
1569	}
1570	return margin + gutter + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest)
1571}
1572
1573func compactToolBlank(th Theme, width int) string {
1574	if width < 1 {
1575		width = 1
1576	}
1577	return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1578}
1579
1580func compactToolHeader(th Theme, label string, width int) string {
1581	label = oneLineToolLabel(label)
1582	name, rest := splitToolLabel(label)
1583	pad := "  "
1584	avail := width - visibleWidth(pad)*2
1585	if avail < 12 {
1586		avail = 12
1587	}
1588	if visibleWidth(name+rest) > avail {
1589		over := visibleWidth(name+rest) - avail
1590		runes := []rune(rest)
1591		if over+3 < len(runes) {
1592			rest = string(runes[:len(runes)-over-3]) + "..."
1593		} else if visibleWidth(name) <= avail {
1594			rest = ""
1595		}
1596	}
1597	plain := pad + name + rest
1598	visible := visibleWidth(plain)
1599	fill := ""
1600	if visible < width {
1601		fill = strings.Repeat(" ", width-visible)
1602	}
1603	bg := th.sgrBGColor(th.UserBubbleBG)
1604	return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset
1605}
1606
1607func toolBodyLine(th Theme, line string, width int, compact bool) string {
1608	line = flatToolBody(th, line)
1609	if compact {
1610		return compactToolBody(th, line, width)
1611	}
1612	return line
1613}
1614
1615// flatToolBody indents a single body line for boxless rendering. It
1616// trims the same leading padding box bodies do, then prepends a fixed
1617// gutter indent so output sits in a readable column under the header
1618// without any vertical frame. ANSI styling in the line is preserved.
1619func flatToolBody(th Theme, line string) string {
1620	_ = th
1621	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1622	return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1623}
1624
1625func compactToolBody(th Theme, line string, width int) string {
1626	visible := visibleWidth(line)
1627	fill := ""
1628	if visible < width {
1629		fill = strings.Repeat(" ", width-visible)
1630	}
1631	bg := th.sgrBGColor(th.UserBubbleBG)
1632	return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1633}
1634
1635// toolBoxBottom renders the bottom edge of a tool block:
1636//
1637//	└─────────────────────────────────────────────────────────────────────────────┘
1638//
1639// Spans the same width as toolBoxTop so the corners line up.
1640func toolBoxBottom(th Theme, width int) string {
1641	w := width - 2*toolBoxOuterMargin
1642	if w < 12 {
1643		w = 12
1644	}
1645	margin := strings.Repeat(" ", toolBoxOuterMargin)
1646	line := "└" + strings.Repeat("─", w-2) + "┘"
1647	return margin + th.FGColor(th.Muted, line) + margin
1648}
1649
1650// hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
1651// iTerm2 (\x1b]1337;File=) inline-image escape. Such rows draw into
1652// the terminal's graphics layer and shouldn't be wrapped with text
1653// box edges.
1654func hasImageEscapeLine(s string) bool {
1655	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
1656}
1657
1658// imageFootprintSentinel marks rows that belong to an inline-image's
1659// reserved footprint — the escape row plus the blank rows below it
1660// plus the gap row before the metadata caption. Any consumer that
1661// wraps content in box edges (│ ... │) detects the sentinel, strips
1662// it, and emits the row — the image graphics rectangle paints over
1663// whatever was drawn there. Uses a non-printing C0 control byte so
1664// it can never appear in normal text or in an ANSI escape sequence
1665// body.
1666//
1667// The escape row carries an extra width hint between two sentinels:
1668// "\x1e<cells>\x1e<spaces+escape>". Wrappers parse the cells count
1669// to know how wide the image is in terminal cells and how many cells
1670// of trailing padding are needed before the closing box edge.
1671const imageFootprintSentinel = "\x1e"
1672
1673// parseImageFootprint splits a footprint-tagged row into (cellsWide,
1674// rest). cellsWide is 0 when the row carries no width hint (the
1675// reservation blanks and gap row don't, only the escape row does).
1676func parseImageFootprint(s string) (int, string) {
1677	if !strings.HasPrefix(s, imageFootprintSentinel) {
1678		return 0, s
1679	}
1680	rest := s[len(imageFootprintSentinel):]
1681	end := strings.Index(rest, imageFootprintSentinel)
1682	if end < 0 {
1683		return 0, rest
1684	}
1685	w, err := strconv.Atoi(rest[:end])
1686	if err != nil {
1687		return 0, rest
1688	}
1689	return w, rest[end+len(imageFootprintSentinel):]
1690}
1691
1692// toolBoxBodyTrimLeft is the number of leading literal spaces stripped
1693// from each body line as it enters toolBoxSide. Body renderers
1694// (renderToolText, renderRawFile, the diff/bash helpers, ...) all
1695// emit a 4-cell indent so the content aligns with the assistant body
1696// column when there's no box around it. Inside a box the box edge +
1697// inner pad already supply most of that gutter, so we drop a couple
1698// of cells from the body's own indent to keep the content from
1699// drifting too far right.
1700const toolBoxBodyTrimLeft = 2
1701
1702// toolBoxBodyRenderWidth is the width body renderers should target before
1703// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
1704// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
1705// then adds the box edge/padding. Passing the full terminal width lets long
1706// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
1707// visually breaking the right edge. This returns the maximum renderer width
1708// whose post-trim visible width fits inside the box.
1709func toolBoxBodyRenderWidth(width int) int {
1710	w := width - 2*toolBoxOuterMargin
1711	if w < 12 {
1712		w = 12
1713	}
1714	inner := w - 2 - 2*toolBoxInnerPad
1715	if inner < 1 {
1716		inner = 1
1717	}
1718	return inner + toolBoxBodyTrimLeft
1719}
1720
1721// flatToolBodyRenderWidth is the width body renderers should target in
1722// flat mode. There's no box frame, just the flatToolBody indent
1723// (toolBoxOuterMargin+2 cells), so the renderable width is the
1724// terminal width minus that indent. Renderers emit a 4-cell indent of
1725// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
1726// so add that back like toolBoxBodyRenderWidth does.
1727func flatToolBodyRenderWidth(width int) int {
1728	inner := width - (toolBoxOuterMargin + 2)
1729	if inner < 1 {
1730		inner = 1
1731	}
1732	return inner + toolBoxBodyTrimLeft
1733}
1734
1735// toolBoxSide wraps a single body line with vertical box edges:
1736//
1737//	│  foo bar baz                                            │
1738//
1739// The middle preserves the line verbatim (including any ANSI escapes)
1740// after stripping toolBoxBodyTrimLeft leading literal spaces. One cell
1741// of inner padding sits on each side so the content breathes from the
1742// edges; the right padding fills to column width-1.
1743func toolBoxSide(th Theme, line string, width int) string {
1744	w := width - 2*toolBoxOuterMargin
1745	if w < 12 {
1746		w = 12
1747	}
1748	margin := strings.Repeat(" ", toolBoxOuterMargin)
1749	mutedEdge := th.FGColor(th.Muted, "│")
1750	left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1751	right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1752	inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
1753	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1754
1755	cur := visibleWidth(line)
1756	if cur > inner {
1757		// Last-resort truncation must retain syntax foreground escapes.
1758		// Stripping ANSI here made long read results switch from their
1759		// configured syntax color to the terminal's default foreground
1760		// whenever the window became narrow enough to clip a line.
1761		line = truncateToWidth(line, inner)
1762		cur = visibleWidth(line)
1763	}
1764	pad := inner - cur
1765	if pad < 0 {
1766		pad = 0
1767	}
1768	return margin + left + line + strings.Repeat(" ", pad) + right + margin
1769}
1770
1771// toolBoxSideWithImage wraps a row that carries an inline-image
1772// escape sequence. The image protocols are configured to not move
1773// the cursor after rendering (iTerm: doNotMoveCursor=1, kitty/
1774// ghostty: C=1), so the only visible cell advance on the row is
1775// from the leading indent spaces before the escape. We pad from
1776// there to the right edge column and draw the closing │ there.
1777// imgCells is unused (kept in the signature for compatibility with
1778// the parseImageFootprint width hint).
1779func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
1780	_ = imgCells
1781	w := width - 2*toolBoxOuterMargin
1782	if w < 12 {
1783		w = 12
1784	}
1785	margin := strings.Repeat(" ", toolBoxOuterMargin)
1786	mutedEdge := th.FGColor(th.Muted, "│")
1787	left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1788	right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1789	inner := w - 2 - 2*toolBoxInnerPad
1790
1791	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1792
1793	// Count leading spaces (visible cells consumed before the escape).
1794	leading := 0
1795	for leading < len(line) && line[leading] == ' ' {
1796		leading++
1797	}
1798
1799	pad := inner - leading
1800	if pad < 0 {
1801		pad = 0
1802	}
1803	return margin + left + line + strings.Repeat(" ", pad) + right + margin
1804}
1805
1806// trimLeadingSpaces removes up to n literal space characters from the
1807// start of s without touching anything else. ANSI CSI escapes that
1808// appear before the spaces are skipped over (so a coloured indent
1809// like "\x1b[2m    text" still loses its first n cells of indent).
1810// Stops as soon as it hits a non-space character.
1811func trimLeadingSpaces(s string, n int) string {
1812	if n <= 0 || s == "" {
1813		return s
1814	}
1815	var b strings.Builder
1816	b.Grow(len(s))
1817	trimmed := 0
1818	i := 0
1819	for i < len(s) {
1820		// Pass through any ANSI escape sequence verbatim.
1821		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1822			end := i + 2
1823			for end < len(s) {
1824				c := s[end]
1825				end++
1826				if c >= 0x40 && c <= 0x7e {
1827					break
1828				}
1829			}
1830			b.WriteString(s[i:end])
1831			i = end
1832			continue
1833		}
1834		if trimmed < n && s[i] == ' ' {
1835			trimmed++
1836			i++
1837			continue
1838		}
1839		b.WriteString(s[i:])
1840		return b.String()
1841	}
1842	return b.String()
1843}
1844
1845// renderToolResultContent renders the body of a tool result block.
1846// Text blocks get the usual rules-wrapped treatment; text that looks
1847// like a unified diff gets +/- coloring. Image blocks are rendered
1848// inline when the terminal supports a protocol, else as a text
1849// placeholder with dimensions.
1850func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
1851	var body []string
1852	hasImage := false
1853	for _, b := range blocks {
1854		if _, ok := b.(provider.ImageBlock); ok {
1855			hasImage = true
1856			break
1857		}
1858	}
1859	total := 0
1860	for _, b := range blocks {
1861		switch bb := b.(type) {
1862		case provider.TextBlock:
1863			bodyWidth := toolBoxBodyRenderWidth(width)
1864			if v.FlatTools || v.CompactMode {
1865				bodyWidth = flatToolBodyRenderWidth(width)
1866			}
1867			estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1868			estimated := estimateToolTextLines(bb.Text, estimateWidth)
1869			total += estimated
1870			text := bb.Text
1871			// A collapsed result only exposes its first ten rendered rows.
1872			// Render a bounded source preview in that case; syntax
1873			// highlighting and wrapping the omitted tail is wasted work.
1874			if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1875				text = previewToolText(text, estimateWidth)
1876			}
1877			body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
1878		case provider.ImageBlock:
1879			body = append(body, v.renderImageBlock(bb, width)...)
1880		}
1881	}
1882	return v.collapseToolBodyWithTotal(body, hasImage, total)
1883}
1884
1885// collapseToolBody trims lines to the configured preview size when the
1886// view is not in ExpandAll mode, appending a muted "... ctrl+o to
1887// expand" footer. Image blocks never collapse — they're short in text
1888// rows but represent real content the user wants to see.
1889func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1890	return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1891}
1892
1893func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
1894	if v.ExpandAll || hasImage {
1895		return lines
1896	}
1897	if total < len(lines) {
1898		total = len(lines)
1899	}
1900	if total <= ToolCollapseLines {
1901		return lines
1902	}
1903	kept := lines
1904	if len(kept) > ToolCollapsePreview {
1905		kept = kept[:ToolCollapsePreview]
1906	}
1907	hidden := total - ToolCollapsePreview
1908	footer := fmt.Sprintf("    ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1909	footer = v.Theme.FGColor(v.Theme.Muted, footer)
1910	out := append([]string(nil), kept...)
1911	out = append(out, "")
1912	return append(out, footer)
1913}
1914
1915func effectiveToolTextWidth(text string, width int) int {
1916	// renderToolText prefixes ordinary rows with a four-cell gutter before
1917	// wrapping them. Diff rows also carry a five-cell read gutter. Estimating
1918	// against the outer body width undercounts long rows and produces an
1919	// incorrect collapse total.
1920	width -= 4
1921	if looksLikeUnifiedDiff(text) {
1922		width -= 5
1923	}
1924	if width < 1 {
1925		return 1
1926	}
1927	return width
1928}
1929
1930func estimateToolTextLines(text string, width int) int {
1931	if width < 1 {
1932		width = 1
1933	}
1934	total := 0
1935	for _, line := range strings.Split(text, "\n") {
1936		cells := runewidth.StringWidth(line)
1937		if cells == 0 {
1938			total++
1939			continue
1940		}
1941		total += (cells + width - 1) / width
1942	}
1943	if total == 0 {
1944		return 1
1945	}
1946	return total
1947}
1948
1949func previewToolText(text string, width int) string {
1950	if width < 1 {
1951		width = 1
1952	}
1953	maxChars := width * (ToolCollapsePreview + 2)
1954	if maxChars < 256 {
1955		maxChars = 256
1956	}
1957	if maxChars > 8192 {
1958		maxChars = 8192
1959	}
1960	end := 0
1961	for count := 0; end < len(text) && count < maxChars; count++ {
1962		_, size := utf8.DecodeRuneInString(text[end:])
1963		if size == 0 {
1964			break
1965		}
1966		end += size
1967	}
1968	preview := text[:end]
1969	lines := strings.SplitAfter(preview, "\n")
1970	if len(lines) > ToolCollapsePreview+2 {
1971		preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1972	}
1973	return preview
1974}
1975
1976// renderToolText renders a text block inside a tool result. If the
1977// text contains a unified-diff section (lines starting with "--- " /
1978// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
1979// colors matching git diff conventions.
1980func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string {
1981	// Legacy path: transcripts saved before we dropped line numbers
1982	// from the read tool still carry "     1\t..." prefixes. Detect and
1983	// strip them, then fall through to the highlighter.
1984	if looksLikeNumberedFile(text) {
1985		return v.renderNumberedFile(text, sourcePath)
1986	}
1987	// If the result embeds a unified diff (the edit tool's output
1988	// starts with a short "applied N edit(s)" line and then a
1989	// standard --- / +++ / +/- patch), render the patch with
1990	// add/remove coloring. This takes priority over the file-like
1991	// detector below because a diff technically has many lines of
1992	// "file content" but what the user cares about is what changed,
1993	// not a dump of the post-edit file.
1994	if looksLikeUnifiedDiff(text) {
1995		return v.renderUnifiedDiff(text, width, sourcePath)
1996	}
1997
1998	// Current path: text came from `read` as raw file bytes. When a
1999	// source path is known (the call had a `path` arg), render with
2000	// a synthetic line-number gutter starting at startLine so the
2001	// on-screen view still looks like cat -n. Doesn't apply to non-
2002	// file tool outputs (bash stdout, display notes, etc.).
2003	if sourcePath != "" && looksLikeFileContent(text) {
2004		return v.renderRawFile(text, sourcePath, startLine)
2005	}
2006
2007	// No truncation — the full tool output is rendered into chat and
2008	// becomes part of the scrollback you can page back through.
2009	lines := strings.Split(text, "\n")
2010
2011	// Bash-result styling: when the first row looks like a shell
2012	// prompt line ("$ ...") emitted by the bash tool, style the
2013	// prompt line in accent and the trailing "[exit N]  Took X.Ys"
2014	// line in muted type so the command + timing read at a glance.
2015	// Everything between is left on the default tool-output color.
2016	if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
2017		return v.renderBashResult(lines, width, defaultColor)
2018	}
2019
2020	inDiff := false
2021	oldLine, newLine := 1, 1
2022	var out []string
2023	for _, l := range lines {
2024		// Detect diff header: "--- name" followed somewhere by "+++ name".
2025		if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
2026			inDiff = true
2027			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, l))
2028			continue
2029		}
2030		// Hunk header "@@ -a,b +c,d @@" resets the counters so patches
2031		// that skip around in the file still get correct numbering.
2032		if inDiff && strings.HasPrefix(l, "@@") {
2033			if o, n, ok := parseHunkHeader(l); ok {
2034				oldLine, newLine = o, n
2035			}
2036			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, l))
2037			continue
2038		}
2039		if inDiff && strings.TrimSpace(l) == "..." {
2040			out = append(out, "")
2041			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, "..."))
2042			continue
2043		}
2044		if inDiff && len(l) > 0 {
2045			switch l[0] {
2046			case '+':
2047				out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2048				newLine++
2049				continue
2050			case '-':
2051				out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2052				oldLine++
2053				continue
2054			case ' ':
2055				out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2056				oldLine++
2057				newLine++
2058				continue
2059			}
2060		}
2061		// Regular line.
2062		for _, w := range wrapLine(l, width-4, "    ") {
2063			out = append(out, "    "+v.Theme.FGColor(defaultColor, w))
2064		}
2065	}
2066	return out
2067}
2068
2069// parseHunkHeader extracts the starting old/new line from a unified
2070// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
2071// header is malformed or missing numbers.
2072func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
2073	// Skip "@@ "
2074	rest := strings.TrimPrefix(l, "@@")
2075	rest = strings.TrimSpace(rest)
2076	if !strings.HasPrefix(rest, "-") {
2077		return 0, 0, false
2078	}
2079	rest = rest[1:]
2080	space := strings.IndexByte(rest, ' ')
2081	if space < 0 {
2082		return 0, 0, false
2083	}
2084	oldPart := rest[:space]
2085	rest = strings.TrimSpace(rest[space+1:])
2086	if !strings.HasPrefix(rest, "+") {
2087		return 0, 0, false
2088	}
2089	rest = rest[1:]
2090	if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
2091		rest = rest[:sp]
2092	}
2093	parseStart := func(s string) (int, bool) {
2094		if c := strings.IndexByte(s, ','); c >= 0 {
2095			s = s[:c]
2096		}
2097		n, err := strconv.Atoi(s)
2098		if err != nil || n < 1 {
2099			return 0, false
2100		}
2101		return n, true
2102	}
2103	o, ok1 := parseStart(oldPart)
2104	n, ok2 := parseStart(rest)
2105	if !ok1 || !ok2 {
2106		return 0, 0, false
2107	}
2108	return o, n, true
2109}
2110
2111// renderDiffRow renders one unified-diff source line as one or more visual
2112// rows. The first row carries the read-style line-number gutter; continuation
2113// rows use a blank gutter so wrapped content cannot be mistaken for another
2114// source line. Code is syntax-highlighted if sourcePath hints at a known
2115// language and otherwise uses the diff color.
2116func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string {
2117	if len(line) == 0 {
2118		return []string{""}
2119	}
2120	code := expandTabs(line[1:]) // strip the leading marker; expand tabs
2121
2122	// Gutter shape: sign + number share a color so they read as one
2123	// visual token ("+123") instead of a neutral line number next to
2124	// a stray marker. Unchanged context lines get a muted gutter and
2125	// a leading space so column alignment stays consistent with +/-
2126	// rows.
2127	var gutterText string
2128	switch mark {
2129	case '+':
2130		gutterText = fmt.Sprintf("+%3d ", lineNo)
2131	case '-':
2132		gutterText = fmt.Sprintf("-%3d ", lineNo)
2133	default:
2134		gutterText = fmt.Sprintf(" %3d ", lineNo)
2135	}
2136	gutterColor := color
2137	if mark == ' ' {
2138		gutterColor = v.Theme.Muted
2139	}
2140
2141	maxCode := width - visibleWidth("    "+gutterText)
2142	if maxCode < 1 {
2143		maxCode = 1
2144	}
2145	chunks := wrapCodeLine(code, maxCode)
2146	lang := LanguageFromPath(sourcePath)
2147	out := make([]string, 0, len(chunks))
2148	for i, chunk := range chunks {
2149		rowGutter := gutterText
2150		if i > 0 {
2151			rowGutter = strings.Repeat(" ", visibleWidth(gutterText))
2152		}
2153		codeRendered := ""
2154		if lang != "" {
2155			if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 {
2156				codeRendered = h[0]
2157			}
2158		}
2159		if codeRendered == "" {
2160			codeColor := color
2161			if mark == ' ' {
2162				codeColor = v.Theme.Muted
2163			}
2164			codeRendered = v.Theme.FGColor(codeColor, chunk)
2165		}
2166		out = append(out, "    "+v.Theme.FGColor(gutterColor, rowGutter)+codeRendered)
2167	}
2168	return out
2169}
2170
2171// wrapCodeLine splits code to a terminal cell width without dropping any
2172// characters. It prefers breaking after whitespace, then falls back to a hard
2173// rune boundary for long tokens. Keeping the whitespace on the preceding row
2174// means joining the returned chunks reconstructs the expanded source exactly.
2175func wrapCodeLine(line string, width int) []string {
2176	if width < 1 || visibleWidth(line) <= width {
2177		return []string{line}
2178	}
2179	runes := []rune(line)
2180	var out []string
2181	for start := 0; start < len(runes); {
2182		cells := 0
2183		end := start
2184		lastBreak := -1
2185		for end < len(runes) {
2186			runeWidth := runewidth.RuneWidth(runes[end])
2187			if cells+runeWidth > width && end > start {
2188				break
2189			}
2190			cells += runeWidth
2191			end++
2192			if runes[end-1] == ' ' || runes[end-1] == '\t' {
2193				lastBreak = end
2194			}
2195			if cells >= width {
2196				break
2197			}
2198		}
2199		if end == start { // A rune wider than the requested width.
2200			end++
2201		}
2202		if end < len(runes) && lastBreak > start {
2203			end = lastBreak
2204		}
2205		out = append(out, string(runes[start:end]))
2206		start = end
2207	}
2208	return out
2209}
2210
2211// renderImageBlock returns the lines for one image, inline if possible.
2212//
2213// Inline image escapes paint into multiple terminal rows but the zut
2214// renderer treats each slice entry as a single row. To prevent chat
2215// content from being drawn on top of the image, we pad with blank rows
2216// so the image's real footprint is reflected in the frame height.
2217func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
2218	w, h := ImageDimensions(b.Data)
2219	kb := len(b.Data) / 1024
2220	// Four-space indent matches every other tool-output row
2221	// (renderRawFile, renderBashResult, diff rows, "... N more
2222	// lines" footers). Keeps the image's metadata line vertically
2223	// aligned with the file content above / below it.
2224	info := fmt.Sprintf("    image - %s - %dx%d - %d KB", b.MimeType, w, h, kb)
2225
2226	if v.ImageProto != ImageProtocolNone {
2227		// Clamp rendered width so the image never overflows the chat
2228		// column. Subtract a 4-cell indent for the tool result block,
2229		// cap at 60 cells, floor at 10.
2230		cells := width - 4
2231		if cells > 60 {
2232			cells = 60
2233		}
2234		if cells < 10 {
2235			cells = 10
2236		}
2237		const maxRows = 20
2238		if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
2239			rows, actualCells := InlineImageFootprint(b.Data, cells, maxRows)
2240			if rows < 1 {
2241				rows = 1
2242			}
2243			if actualCells < 1 {
2244				actualCells = cells
2245			}
2246			// Reserve the image footprint first, then place the escape
2247			// in the top-left of that blank rectangle. Keeping the
2248			// metadata above the image made some terminals blend text
2249			// and graphics layers during full-screen repaints; metadata
2250			// below the reserved rectangle is more stable and easier to
2251			// read. One extra blank row before the metadata gives the
2252			// caption breathing room from the image's last pixel row.
2253			//
2254			// Every footprint row (escape, reservations, gap) is tagged
2255			// with imageFootprintSentinel so callers wrapping content in
2256			// box edges can recognise these rows and emit them bare —
2257			// the image's graphics rectangle paints over them and any
2258			// │ character drawn alongside would visibly bleed through.
2259			// 4 leading cells push the escape past the box's left
2260			// edge plus a small interior gutter so the image rectangle
2261			// sits visibly inside the frame instead of kissing the │.
2262			// The escape row carries a width hint after the sentinel
2263			// ("\x1e<cells>\x1e...") so toolBoxSide knows how many
2264			// cells the image occupies and can pad to the right edge.
2265			widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)
2266			out := make([]string, 0, rows+3)
2267			out = append(out, widthHint+"    "+seq)
2268			for i := 1; i < rows; i++ {
2269				out = append(out, imageFootprintSentinel)
2270			}
2271			out = append(out, imageFootprintSentinel)
2272			out = append(out, v.Theme.FGColor(v.Theme.Muted, info))
2273			return out
2274		}
2275	}
2276	return []string{v.Theme.FGColor(v.Theme.Muted, info)}
2277}
2278
2279// looksLikeNumberedFile returns true when text matches the `read`
2280// tool's "     N\tcontent" format for most of its lines.
2281func looksLikeNumberedFile(text string) bool {
2282	lines := strings.Split(text, "\n")
2283	if len(lines) == 0 {
2284		return false
2285	}
2286	hits := 0
2287	scanned := 0
2288	for _, l := range lines {
2289		if l == "" {
2290			continue
2291		}
2292		scanned++
2293		if scanned > 20 {
2294			break
2295		}
2296		if numberedLineRE.MatchString(l) {
2297			hits++
2298		}
2299	}
2300	if scanned == 0 {
2301		return false
2302	}
2303	return hits*2 >= scanned // majority of non-empty lines match
2304}
2305
2306var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
2307
2308// renderNumberedFile strips line numbers, highlights the code, and
2309// re-attaches the line numbers in muted color.
2310func (v *View) renderNumberedFile(text, sourcePath string) []string {
2311	lines := strings.Split(text, "\n")
2312	gutters := make([]string, 0, len(lines))
2313	codes := make([]string, 0, len(lines))
2314	for _, l := range lines {
2315		idx := strings.IndexByte(l, '\t')
2316		if idx < 0 || !numberedLineRE.MatchString(l) {
2317			// Non-code footer (e.g. "[truncated at 2000 lines]").
2318			gutters = append(gutters, "")
2319			codes = append(codes, l)
2320			continue
2321		}
2322		gutter := l[:idx] + " " // replace tab with single space
2323		code := expandTabs(l[idx+1:])
2324		gutters = append(gutters, gutter)
2325		codes = append(codes, code)
2326	}
2327	lang := LanguageFromPath(sourcePath)
2328	var highlighted []string
2329	if lang != "" {
2330		highlighted = v.Theme.HighlightCode(strings.Join(codes, "\n"), lang)
2331		// Chroma sometimes collapses the trailing empty line. Pad to
2332		// align with the gutter slice so per-line zipping works.
2333		for len(highlighted) < len(codes) {
2334			highlighted = append(highlighted, "")
2335		}
2336		if len(highlighted) > len(codes) {
2337			highlighted = highlighted[:len(codes)]
2338		}
2339	} else {
2340		// No lexer for this file type — render in the ToolOut color so
2341		// code is visually distinct from the muted gutter.
2342		highlighted = make([]string, len(codes))
2343		for i, c := range codes {
2344			highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2345		}
2346	}
2347	out := make([]string, 0, len(codes))
2348	for i, code := range highlighted {
2349		g := gutters[i]
2350		if g == "" {
2351			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, code))
2352			continue
2353		}
2354		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, g)+code)
2355	}
2356	return out
2357}
2358
2359// looksLikeFileContent is a cheap guard to distinguish a read-tool
2360// result from bash stdout or a status message. File content usually
2361// contains characters that status messages don't (code punctuation,
2362// longer lines, multiple lines) and rarely starts with the "  >"-
2363// or "error:"-style prefixes tools emit. False positives are OK,
2364// the worst case is a line-number gutter on something that isn't
2365// really code.
2366// renderBashResult styles a bash tool result: the "$ command" first
2367// line in the accent color, the trailing "[exit N]  Took X.Ys" line
2368// in muted type, everything else on the default tool-output color.
2369// Called from renderToolText when the first line starts with "$ ".
2370func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string {
2371	lines = normalizeBashOutputLines(lines)
2372	// Identify the footer line (exit + timing). The bash tool writes
2373	// it as the last non-empty line of the result.
2374	footerIdx := -1
2375	for i := len(lines) - 1; i >= 0; i-- {
2376		trimmed := strings.TrimSpace(lines[i])
2377		if trimmed == "" {
2378			continue
2379		}
2380		if strings.HasPrefix(trimmed, "[exit ") {
2381			footerIdx = i
2382		}
2383		break
2384	}
2385
2386	var out []string
2387	for i, l := range lines {
2388		switch {
2389		case i == 0 && strings.HasPrefix(l, "$ "):
2390			// Style the $ and the command text in accent. No
2391			// further processing / wrapping so the shell-style
2392			// prompt reads at a glance.
2393			for _, w := range wrapLine(l, width-4, "    ") {
2394				out = append(out, "    "+v.Theme.FGColor(v.Theme.Accent, w))
2395			}
2396		case i == footerIdx:
2397			for _, w := range wrapLine(l, width-4, "    ") {
2398				out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, w))
2399			}
2400		default:
2401			for _, w := range wrapLine(l, width-4, "    ") {
2402				out = append(out, "    "+v.Theme.FGColor(defaultColor, w))
2403			}
2404		}
2405	}
2406	return out
2407}
2408
2409// normalizeBashOutputLines turns arbitrary terminal output into plain,
2410// box-safe rows before wrapping. Unlike read/write/edit output, bash
2411// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes,
2412// and other C0 controls from subprocesses. If those reach the bordered
2413// tool box, the width calculator can undercount what the terminal will
2414// draw and the right edge appears broken. Keep printable text, expand
2415// tabs to spaces, split carriage-return progress rows, and drop escape
2416// / control sequences.
2417func normalizeBashOutputLines(lines []string) []string {
2418	var out []string
2419	for _, line := range lines {
2420		for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") {
2421			out = append(out, normalizeBashOutputLine(part))
2422		}
2423	}
2424	if len(out) == 0 {
2425		return []string{""}
2426	}
2427	return out
2428}
2429
2430func normalizeBashOutputLine(s string) string {
2431	var b strings.Builder
2432	col := 0
2433	for i := 0; i < len(s); {
2434		c := s[i]
2435		if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes.
2436			i = skipEscapeSequence(s, i)
2437			continue
2438		}
2439		r, size := utf8.DecodeRuneInString(s[i:])
2440		if r == utf8.RuneError && size == 1 {
2441			i++
2442			continue
2443		}
2444		switch r {
2445		case '\t':
2446			spaces := 8 - (col % 8)
2447			if spaces == 0 {
2448				spaces = 8
2449			}
2450			b.WriteString(strings.Repeat(" ", spaces))
2451			col += spaces
2452		case '\b':
2453			// Backspace-overstrike output is common in spinners/progress bars.
2454			// Dropping it is safer than moving the TUI cursor backwards.
2455		case '\n', '\r':
2456			// Already split by normalizeBashOutputLines.
2457		default:
2458			if r < 0x20 || r == 0x7f {
2459				// Drop non-printing controls.
2460				break
2461			}
2462			b.WriteRune(r)
2463			col += runewidth.RuneWidth(r)
2464		}
2465		i += size
2466	}
2467	return b.String()
2468}
2469
2470func skipEscapeSequence(s string, i int) int {
2471	if i >= len(s) || s[i] != 0x1b {
2472		return i + 1
2473	}
2474	if i+1 >= len(s) {
2475		return len(s)
2476	}
2477	switch s[i+1] {
2478	case '[': // CSI: ESC [ ... final byte 0x40-0x7e
2479		j := i + 2
2480		for j < len(s) {
2481			c := s[j]
2482			j++
2483			if c >= 0x40 && c <= 0x7e {
2484				break
2485			}
2486		}
2487		return j
2488	case ']': // OSC: ESC ] ... BEL or ST
2489		return skipStringEscape(s, i+2)
2490	case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
2491		return skipStringEscape(s, i+2)
2492	default:
2493		// Two-byte escape (cursor save/restore, charset select, etc.).
2494		return i + 2
2495	}
2496}
2497
2498func skipStringEscape(s string, i int) int {
2499	for i < len(s) {
2500		if s[i] == 0x07 { // BEL
2501			return i + 1
2502		}
2503		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
2504			return i + 2
2505		}
2506		i++
2507	}
2508	return len(s)
2509}
2510
2511// looksLikeUnifiedDiff reports whether text is a context diff as
2512// emitted by the edit tool: rows start with '+', '-', ' ', or
2513// literal "..." (context-break marker). The presence of at least
2514// one '+' or '-' row distinguishes a real diff from an ordinary
2515// file whose lines happen to begin with a space.
2516func looksLikeUnifiedDiff(text string) bool {
2517	lines := strings.Split(text, "\n")
2518	if len(lines) < 2 {
2519		return false
2520	}
2521	sawChange := false
2522	for _, l := range lines {
2523		if l == "" {
2524			continue
2525		}
2526		if l == "..." {
2527			continue
2528		}
2529		switch l[0] {
2530		case '+', '-':
2531			sawChange = true
2532		case ' ':
2533			// context, ok
2534		default:
2535			return false
2536		}
2537	}
2538	return sawChange
2539}
2540
2541// renderUnifiedDiff renders the edit tool's context diff. Each
2542// kept row shows a line-number gutter plus a marker column: '+'
2543// for additions (colored like add), '-' for deletions (colored
2544// like remove), and unmarked context in muted type. A literal
2545// "..." line between hunks renders as an ellipsis in muted type,
2546// indicating skipped unchanged rows. The old and new line
2547// counters advance so each row carries its actual position in
2548// the pre- or post-edit file. Heuristic: when we hit a "...", we
2549// can't know where the next hunk starts, so we don't reset the
2550// counters — they stay approximate in the rare multi-hunk case.
2551func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
2552	lines := strings.Split(text, "\n")
2553	if n := len(lines); n > 0 && lines[n-1] == "" {
2554		lines = lines[:n-1]
2555	}
2556	oldLine, newLine := 1, 1
2557	var out []string
2558	for _, l := range lines {
2559		if l == "" {
2560			out = append(out, "")
2561			continue
2562		}
2563		if l == "..." {
2564			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, "..."))
2565			continue
2566		}
2567		switch l[0] {
2568		case '+':
2569			out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2570			newLine++
2571		case '-':
2572			out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2573			oldLine++
2574		case ' ':
2575			out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2576			oldLine++
2577			newLine++
2578		default:
2579			for _, w := range wrapLine(l, width-4, "    ") {
2580				out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, w))
2581			}
2582		}
2583	}
2584	return out
2585}
2586
2587func looksLikeFileContent(text string) bool {
2588	if strings.TrimSpace(text) == "" {
2589		return false
2590	}
2591	lines := strings.Split(text, "\n")
2592	return len(lines) >= 2
2593}
2594
2595// renderRawFile renders file content received without embedded line
2596// numbers (the current read-tool output). Draws a muted gutter like
2597// "%6d \t" starting at startLine, highlights the code using the
2598// source path's language, and returns the formatted lines.
2599func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
2600	lines := strings.Split(text, "\n")
2601	// Drop the trailing empty line that Split produces when text ends
2602	// in "\n" so the gutter doesn't show a phantom last number.
2603	if n := len(lines); n > 0 && lines[n-1] == "" {
2604		lines = lines[:n-1]
2605	}
2606	// Split code from trailing footer lines ("... [truncated ...]")
2607	// so we don't number the footer. A single blank separator line
2608	// immediately before the footer is also pulled out so the
2609	// gutter doesn't render a phantom number for it.
2610	codeEnd := len(lines)
2611	for i := len(lines) - 1; i >= 0; i-- {
2612		if strings.HasPrefix(lines[i], "...") {
2613			codeEnd = i
2614			continue
2615		}
2616		break
2617	}
2618	if codeEnd > 0 && codeEnd < len(lines) && lines[codeEnd-1] == "" {
2619		codeEnd--
2620	}
2621	code := lines[:codeEnd]
2622	footer := lines[codeEnd:]
2623
2624	// Expand tabs to 4 spaces so Go / Makefile code renders
2625	// at a consistent width.
2626	for i, c := range code {
2627		code[i] = expandTabs(c)
2628	}
2629
2630	lang := LanguageFromPath(sourcePath)
2631	var highlighted []string
2632	if lang != "" {
2633		highlighted = v.Theme.HighlightCode(strings.Join(code, "\n"), lang)
2634		for len(highlighted) < len(code) {
2635			highlighted = append(highlighted, "")
2636		}
2637		if len(highlighted) > len(code) {
2638			highlighted = highlighted[:len(code)]
2639		}
2640	} else {
2641		highlighted = make([]string, len(code))
2642		for i, c := range code {
2643			highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2644		}
2645	}
2646
2647	out := make([]string, 0, len(lines))
2648	for i, c := range highlighted {
2649		gutter := fmt.Sprintf("%4d ", startLine+i)
2650		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, gutter)+c)
2651	}
2652	for _, f := range footer {
2653		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, f))
2654	}
2655	return out
2656}
2657
2658// defaultToolArgWidth is the number of cells the tool-call header
2659// truncates the primary argument (path/command/query) to when the
2660// ZUT_TOOL_ARG_WIDTH environment variable is unset or invalid.
2661const defaultToolArgWidth = 60
2662
2663// toolArgWidth returns the maximum cell width for the truncated
2664// primary argument shown in a tool-call header. It defaults to
2665// defaultToolArgWidth but can be raised or lowered with the
2666// ZUT_TOOL_ARG_WIDTH environment variable, which is useful for wide
2667// terminals where the default clips long queries too aggressively.
2668// Values outside the sane range [20, 500] are ignored so a stray or
2669// malformed setting can never produce an unreadable header or an
2670// out-of-range slice.
2671func toolArgWidth() int {
2672	v := strings.TrimSpace(os.Getenv("ZUT_TOOL_ARG_WIDTH"))
2673	if v == "" {
2674		return defaultToolArgWidth
2675	}
2676	n, err := strconv.Atoi(v)
2677	if err != nil || n < 20 || n > 500 {
2678		return defaultToolArgWidth
2679	}
2680	return n
2681}
2682
2683// ShortArgs renders a tool call's arguments into a one-line
2684// suffix for the "tool name <args>" header. tool is the tool
2685// name so we can add shape-specific decorations: for read we
2686// append the requested line range (e.g. "path:1-200") pulled
2687// from the offset/limit args, which is useful context at a
2688// glance without expanding the result body. Other tools keep
2689// the legacy "path or command, truncated" shape.
2690//
2691// The truncation width defaults to 60 cells but can be tuned via
2692// the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).
2693//
2694// Exported because the interactive mode pre-populates the
2695// ToolCallView.Args field with this value as soon as the tool
2696// call is announced, so the live overlay's header matches what
2697// the finalised transcript will later render.
2698func ShortArgs(tool string, raw json.RawMessage) string {
2699	width := toolArgWidth()
2700	var v any
2701	if err := json.Unmarshal(raw, &v); err != nil {
2702		return ""
2703	}
2704	x, ok := v.(map[string]any)
2705	if !ok {
2706		b, _ := json.Marshal(v)
2707		s := oneLineToolLabel(string(b))
2708		if len(s) > width {
2709			s = s[:width-3] + "..."
2710		}
2711		return s
2712	}
2713	var primary string
2714	for _, k := range []string{"path", "file_path", "command"} {
2715		if s, ok := x[k].(string); ok {
2716			primary = s
2717			break
2718		}
2719	}
2720	if primary == "" {
2721		b, _ := json.Marshal(v)
2722		s := oneLineToolLabel(string(b))
2723		if len(s) > width {
2724			s = s[:width-3] + "..."
2725		}
2726		return s
2727	}
2728	primary = oneLineToolLabel(primary)
2729
2730	// Tool-specific decoration. Only the read tool gets a range
2731	// suffix for now; other tools just truncate the primary arg.
2732	suffix := ""
2733	switch strings.ToLower(tool) {
2734	case "read":
2735		start := 1
2736		if n, ok := toInt(x["offset"]); ok && n >= 1 {
2737			start = n
2738		}
2739		if lim, ok := toInt(x["limit"]); ok && lim > 0 {
2740			end := start + lim - 1
2741			suffix = fmt.Sprintf(":%d-%d", start, end)
2742		} else if start > 1 {
2743			suffix = fmt.Sprintf(":%d-", start)
2744		}
2745	}
2746
2747	// Truncate the primary arg leaving room for the suffix so the
2748	// range stays visible even on absurdly long paths.
2749	max := width - len(suffix)
2750	if max < 10 {
2751		max = 10
2752	}
2753	if len(primary) > max {
2754		primary = primary[:max-3] + "..."
2755	}
2756	return primary + suffix
2757}
2758
2759// toInt coerces a json.Unmarshal'd number (float64) or a string
2760// containing a number into an int. Returns ok=false if the value
2761// is neither. Used by shortArgs to survive model quirks where
2762// numeric args come back as strings.
2763func toInt(v any) (int, bool) {
2764	switch n := v.(type) {
2765	case float64:
2766		return int(n), true
2767	case int:
2768		return n, true
2769	case string:
2770		i, err := strconv.Atoi(strings.TrimSpace(n))
2771		if err != nil {
2772			return 0, false
2773		}
2774		return i, true
2775	}
2776	return 0, false
2777}
2778
2779// renderCompactionBlock renders a compaction summary as a distinct
2780// visual block in the chat. When collapsed it shows a one-line label
2781// with the pre-compaction token count; when expanded (ctrl+o) it
2782// shows the full summary text.
2783func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2784	th := v.Theme
2785	const indent = "    "
2786
2787	tokens := m.Meta["tokens_before"]
2788	if tokens == "" {
2789		tokens = "?"
2790	}
2791
2792	if v.ExpandAll {
2793		var lines []string
2794		header := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
2795		lines = append(lines, indent+header)
2796		lines = append(lines, "")
2797		for _, c := range m.Content {
2798			if tb, ok := c.(provider.TextBlock); ok {
2799				text := tb.Text
2800				if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
2801					text = text[idx+2:]
2802				}
2803				md := RenderMarkdown(text, th, width-4)
2804				for _, l := range strings.Split(md, "\n") {
2805					if len(l) > 0 && l[0] == FlushLeftSentinel {
2806						l = l[1:]
2807					}
2808					lines = append(lines, indent+l)
2809				}
2810			}
2811		}
2812		return lines
2813	}
2814
2815	// Collapsed: single line, no banner.
2816	line := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
2817	return []string{indent + line}
2818}
2819
2820// StatusBarParams groups the many bits of state the status bar needs.
2821// Grew from a flat argument list once we settled on the layout.
2822type StatusBarParams struct {
2823	Theme      Theme
2824	Provider   string
2825	Model      string
2826	Reasoning  string // "" means thinking off
2827	FastMode   bool   // show the provider's opt-in fast tier when enabled
2828	Busy       bool
2829	BusyPrefix string // spinner + funny line when busy
2830	CWD        string
2831	Locked     bool // sandbox on?
2832	NoYolo     bool // confirmation mode enabled?
2833
2834	// Cumulative session usage and cost.
2835	Usage provider.Usage
2836	// Subscription is true when the credential is an OAuth token (claude
2837	// pro/max, chatgpt plus/pro) rather than a paid api key. We still
2838	// compute a cost for visibility and append "(sub)" so the user
2839	// knows no real money moved.
2840	Subscription bool
2841
2842	// Last turn's input+cache tokens (approximates current live context).
2843	ContextUsed int
2844	ContextMax  int // model's context window; 0 disables the percentage
2845
2846	// AutoCompacting is true when the agent is currently running a
2847	// model-triggered condense pass. Surfaces as "(auto)" after the
2848	// context percentage so it's clear where the spinner is coming from.
2849	AutoCompacting bool
2850
2851	// Telegram true when the telegram bridge is connected. Adds a
2852	// small "- tg -" tag to the cwd line so the user can tell at a
2853	// glance that dms are being mirrored into this session.
2854	Telegram bool
2855
2856	Cols int // terminal width; drives right-alignment of cwd
2857}
2858
2859// StatusBar builds the status shown above the editor. Always returns
2860// two lines when a cwd is provided: the stats on the first line, the
2861// cwd on its own line below, indented to match the stats column. This
2862// keeps the status bar stable across terminal resizes (the cwd never
2863// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
2864// makes a long cwd safe at any width.
2865//
2866// Layout:
2867//
2868//	<busyPrefix>  (provider) model  stats   <- line 1
2869//	  cwd                                   <- line 2 (2-space indent)
2870//
2871// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
2872// The slash-command popup and the queued/sliding-in chips already
2873// cover the discoverability of those keybindings.
2874func StatusBar(p StatusBarParams) []string {
2875	th := p.Theme
2876
2877	// Token stats: only include each segment when non-zero. Keeps
2878	// the bar compact on brand-new sessions.
2879	var stats []string
2880	if p.Usage.InputTokens > 0 {
2881		stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
2882	}
2883	if p.Usage.OutputTokens > 0 {
2884		stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
2885	}
2886	if p.Usage.CacheReadTokens > 0 {
2887		stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
2888	}
2889	if p.Usage.CacheWriteTokens > 0 {
2890		stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
2891	}
2892
2893	// Cost: always show the dollar value computed from token counts,
2894	// even on subscription. Lets you see what the equivalent api cost
2895	// would be (handy for gauging subscription value). Append "(sub)"
2896	// only as a hint that no real money moved.
2897	var costStr string
2898	if p.Usage.CostUSD > 0 || p.Subscription {
2899		costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
2900		if p.Subscription {
2901			costStr += " (sub)"
2902		}
2903	}
2904	if costStr != "" {
2905		stats = append(stats, costStr)
2906	}
2907
2908	// Context %. Color-coded: yellow >70, red >90.
2909	ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
2910	if ctx != "" {
2911		if p.AutoCompacting {
2912			ctx += " (auto)"
2913		}
2914		stats = append(stats, th.FGColor(ctxColor, ctx))
2915	}
2916
2917	// Layout uses exactly 2 spaces of horizontal padding everywhere:
2918	//   2 spaces  (openai) gpt-5.4  $0.000 (sub) 0.0%/400k  ~/Sites/zut
2919	// matches the editor prompt's left inset so the bar lines up
2920	// vertically with the conversation column.
2921	const pad = "  " // 2 spaces
2922
2923	left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2924	reasoning := reasoningLevelLabel(p.Reasoning)
2925	reasoningText := ""
2926	if reasoning != "" {
2927		reasoningText = "reasoning: " + reasoning
2928	}
2929	fastText := ""
2930	if p.FastMode {
2931		fastText = "fast mode"
2932	}
2933	statsText := strings.Join(stats, " ")
2934	middleParts := make([]string, 0, 3)
2935	if fastText != "" {
2936		middleParts = append(middleParts, fastText)
2937	}
2938	if reasoningText != "" {
2939		middleParts = append(middleParts, reasoningText)
2940	}
2941	if statsText != "" {
2942		middleParts = append(middleParts, statsText)
2943	}
2944	middle := strings.Join(middleParts, "  ")
2945
2946	var leftBuilder strings.Builder
2947	if p.BusyPrefix != "" {
2948		// Don't re-color the payload itself — the caller already set
2949		// fg colors on the spinner glyph, message, and elapsed
2950		// segments. Wrapping the whole thing here would override
2951		// those choices. The pad itself needs no color (it's spaces).
2952		leftBuilder.WriteString(pad + p.BusyPrefix)
2953		// Exactly one pad (2 spaces) between the busy segment and
2954		// the provider/model block. The leading pad above covers
2955		// the left indent.
2956		leftBuilder.WriteString(pad)
2957	} else {
2958		// Idle path: a single pad of left inset so the line
2959		// aligns with the conversation column on its left edge
2960		// ("  you" / "  zut" message markers). Without the busy
2961		// prefix there's no trailing separator to double-pad.
2962		leftBuilder.WriteString(pad)
2963	}
2964	leftBuilder.WriteString(th.FGColor(th.Muted, left))
2965	if middle != "" {
2966		leftBuilder.WriteString(pad)
2967		// Highlight the opt-in max tier; other status information stays muted.
2968		leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
2969	}
2970
2971	cwd := shortenHome(p.CWD)
2972	tags := ""
2973	if p.NoYolo {
2974		tags += "yolo mode disabled "
2975	}
2976	if p.Locked {
2977		tags += "jailed "
2978	}
2979	if p.Telegram {
2980		tags += "telegram connected "
2981	}
2982	if tags != "" && cwd != "" {
2983		cwd = tags + "- " + cwd
2984	}
2985
2986	primary := leftBuilder.String()
2987
2988	// On narrow terminals the single line wraps badly. If the visible
2989	// width exceeds cols and we have a busy prefix, split: keep the
2990	// busy prefix on line 1, then put model and (if still needed)
2991	// stats on their own rows. This mirrors the idle split below.
2992	if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
2993		busyLine := pad + p.BusyPrefix
2994		modelLine := pad + th.FGColor(th.Muted, left)
2995		lines := []string{busyLine}
2996		if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
2997			lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
2998		} else {
2999			var infoBuilder strings.Builder
3000			infoBuilder.WriteString(modelLine)
3001			if middle != "" {
3002				infoBuilder.WriteString(pad)
3003				infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
3004			}
3005			lines = append(lines, infoBuilder.String())
3006		}
3007		if cwd != "" {
3008			lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3009		}
3010		return lines
3011	}
3012
3013	// Idle narrow split: keep provider/model on the first status line,
3014	// move usage/cost/context stats to the next, then cwd below. This
3015	// avoids the terminal's hard wrap cutting the stats or pushing cwd
3016	// into an awkward position on small widths.
3017	if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
3018		var lines []string
3019		lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
3020		if cwd != "" {
3021			lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3022		}
3023		return lines
3024	}
3025
3026	if cwd == "" {
3027		return []string{primary}
3028	}
3029
3030	// Second line: indent with the same 2-space pad so the cwd lines
3031	// up under the "(provider)" column on line 1.
3032	cwdRendered := pad + th.FGColor(th.Muted, cwd)
3033	return []string{primary, cwdRendered}
3034}
3035
3036func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {
3037	modelLine := pad + th.FGColor(th.Muted, modelText)
3038	infoParts := make([]string, 0, 2)
3039	if fastText != "" {
3040		infoParts = append(infoParts, fastText)
3041	}
3042	if reasoningText != "" {
3043		infoParts = append(infoParts, reasoningText)
3044	}
3045	infoText := strings.Join(infoParts, pad)
3046	if infoText == "" {
3047		lines = append(lines, modelLine)
3048		if statsText != "" {
3049			lines = append(lines, pad+th.FGColor(th.Muted, statsText))
3050		}
3051		return lines
3052	}
3053
3054	modelInfoPlain := pad + modelText + pad + infoText
3055	infoColor := th.Muted
3056	if reasoningText != "" {
3057		infoColor = reasoningStatusColor(th, reasoningText)
3058	}
3059	if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
3060		lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
3061	} else {
3062		lines = append(lines, modelLine)
3063		if fastText != "" {
3064			lines = append(lines, pad+th.FGColor(th.Muted, fastText))
3065		}
3066		if reasoningText != "" {
3067			lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
3068		}
3069	}
3070	if statsText != "" {
3071		lines = append(lines, pad+th.FGColor(th.Muted, statsText))
3072	}
3073	return lines
3074}
3075
3076func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3077	if strings.HasSuffix(reasoningText, ": max") {
3078		return th.ThinkingMax
3079	}
3080	return th.Muted
3081}
3082
3083func reasoningLevelLabel(level string) string {
3084	switch strings.ToLower(strings.TrimSpace(level)) {
3085	case "", "off", "none", "no", "false", "disabled":
3086		return ""
3087	case "minimum", "minimal", "min":
3088		return "minimal"
3089	case "maximum", "xhigh":
3090		return "xhigh"
3091	case "max":
3092		return "max"
3093	default:
3094		return strings.ToLower(strings.TrimSpace(level))
3095	}
3096}
3097
3098// contextUsage renders the "N%/ctxMax" fragment, returning the
3099// rendered string plus the colour to wrap it in.
3100func contextUsage(th Theme, used, max int) (string, TerminalColor) {
3101	if max <= 0 {
3102		if used <= 0 {
3103			return "", th.Muted
3104		}
3105		return formatTokens(used), th.Muted
3106	}
3107	pct := float64(used) / float64(max) * 100
3108	text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
3109	switch {
3110	case pct > 90:
3111		return text, th.Error
3112	case pct > 70:
3113		return text, th.Warning
3114	}
3115	return text, th.Muted
3116}
3117
3118// formatTokens footer formatter:
3119//
3120//	< 1000      -> "42"
3121//	< 10_000    -> "2.7k"
3122//	< 1_000_000 -> "35k"
3123//	< 10M       -> "1.1M"
3124//	else        -> "12M"
3125func formatTokens(n int) string {
3126	switch {
3127	case n < 0:
3128		return "0"
3129	case n < 1000:
3130		return fmt.Sprintf("%d", n)
3131	case n < 10000:
3132		return fmt.Sprintf("%.1fk", float64(n)/1000)
3133	case n < 1_000_000:
3134		return fmt.Sprintf("%dk", (n+500)/1000)
3135	case n < 10_000_000:
3136		return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
3137	default:
3138		return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
3139	}
3140}
3141
3142// shortenHome replaces the user's $HOME prefix with "~" for readability.
3143func shortenHome(p string) string {
3144	if p == "" {
3145		return ""
3146	}
3147	home := osUserHome()
3148	if home == "" {
3149		return p
3150	}
3151	if p == home {
3152		return "~"
3153	}
3154	if strings.HasPrefix(p, home+"/") {
3155		return "~" + p[len(home):]
3156	}
3157	return p
3158}
3159
3160// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
3161var osUserHome = func() string {
3162	if h, err := osUserHomeDir(); err == nil {
3163		return h
3164	}
3165	return ""
3166}

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