v0.20.0 29be61c

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

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