v0.25.0 c3476a8

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	h = fnv64aWriteByte(h, byte(theme.Terminal.ColorDepth()))
1212	h = fnv64aWriteUint64(h, uint64(theme.Terminal.PaletteKnown))
1213	for _, value := range []bool{
1214		theme.UseTerminalPalette,
1215		theme.Terminal.HasForeground,
1216		theme.Terminal.HasBackground,
1217		theme.Terminal.TrueColor,
1218		theme.Terminal.Light,
1219		theme.Terminal.SchemeKnown,
1220	} {
1221		if value {
1222			h = fnv64aWriteByte(h, 1)
1223		} else {
1224			h = fnv64aWriteByte(h, 0)
1225		}
1226	}
1227	return h
1228}
1229
1230func hashThemeColor(h uint64, c TerminalColor) uint64 {
1231	h = fnv64aWriteByte(h, byte(c.Mode))
1232	h = fnv64aWriteByte(h, byte(c.Index))
1233	h = fnv64aWriteByte(h, byte(c.R))
1234	h = fnv64aWriteByte(h, byte(c.G))
1235	h = fnv64aWriteByte(h, byte(c.B))
1236	return h
1237}
1238
1239func (v *View) renderToolCall(tc ToolCallView, width int) []string {
1240	var lines []string
1241
1242	// Header label. While the call is still streaming, prefer the
1243	// live path extracted from the partial args so the user sees
1244	// the target file as soon as it's known, even before the full
1245	// JSON arrived.
1246	arg := tc.Args
1247	if arg == "" && tc.LivePath != "" {
1248		arg = tc.LivePath
1249	}
1250	label := tc.Name + " " + arg
1251
1252	// No leading blank: Build()'s inter-message separator already
1253	// places one blank row between the previous transcript content
1254	// and the live overlay, matching the spacing the same call has
1255	// once it finalises into a transcript box. Adding another blank
1256	// here would double the gap during streaming and visibly tighten
1257	// when the overlay disappears.
1258
1259	// Live body (write/edit): keep the streamed preview visible until
1260	// a proposed or real tool result arrives. The provider can finish the
1261	// tool_use JSON before zut has executed the tool, so keying this on
1262	// tc.Streaming makes write/edit boxes collapse for a moment between
1263	// EvToolUseEnd and confirmation or EvToolResult.
1264	if tc.Preview == "" && tc.Result == "" {
1265		if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
1266			// Pad the live preview up to the call's high-water height so
1267			// it never shrinks mid-stream (e.g. between edit 1 and the
1268			// start of edit 2). Padding sits inside the box as real
1269			// box-side rows so the surrounding layout stays put. The
1270			// high-water mark is per call id and is dropped the moment a
1271			// Result lands (this branch only runs while Result == "").
1272			high := len(body)
1273			// Live edits otherwise grow from a few rows to the collapsed
1274			// preview limit as newText arrives, moving the editor on every
1275			// line. Reserve the final collapsed height from the first visible
1276			// edit frame; ctrl+o still opts into unconstrained growth.
1277			if !v.ExpandAll && (tc.Name == "edit" || tc.Name == "Edit") && high < ToolCollapsePreview+2 {
1278				high = ToolCollapsePreview + 2
1279			}
1280			if v.liveBodyHigh != nil {
1281				if prev := v.liveBodyHigh[tc.ID]; prev > high {
1282					high = prev
1283				}
1284				v.liveBodyHigh[tc.ID] = high
1285			}
1286			if v.FlatTools || v.CompactMode {
1287				for len(body) < high {
1288					body = append(body, "")
1289				}
1290				if v.CompactMode {
1291					lines = append(lines, compactToolBlank(v.Theme, width))
1292				}
1293				lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1294				lines = append(lines, body...)
1295				if v.CompactMode {
1296					lines = append(lines, compactToolBlank(v.Theme, width))
1297				}
1298				return lines
1299			}
1300			for len(body) < high {
1301				body = append(body, toolBoxSide(v.Theme, "", width))
1302			}
1303			lines = append(lines, toolBoxTop(v.Theme, label, width))
1304			lines = append(lines, toolBoxSide(v.Theme, "", width))
1305			lines = append(lines, body...)
1306			lines = append(lines, toolBoxSide(v.Theme, "", width))
1307			lines = append(lines, toolBoxBottom(v.Theme, width))
1308			return lines
1309		}
1310	}
1311
1312	bodyText := tc.Result
1313	if bodyText == "" {
1314		bodyText = tc.Preview
1315	}
1316
1317	// Finished tool call with no body: just the labelled top edge
1318	// directly closed by the bottom. Avoids a blank interior row
1319	// for no-output tools.
1320	if bodyText == "" {
1321		if v.FlatTools || v.CompactMode {
1322			if v.CompactMode {
1323				lines = append(lines, compactToolBlank(v.Theme, width))
1324			}
1325			lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1326			if v.CompactMode {
1327				lines = append(lines, compactToolBlank(v.Theme, width))
1328			}
1329			return lines
1330		}
1331		lines = append(lines, toolBoxTop(v.Theme, label, width))
1332		lines = append(lines, toolBoxBottom(v.Theme, width))
1333		return lines
1334	}
1335
1336	// Finished tool call with a result body. Top edge embeds the
1337	// label; body lines go through toolBoxSide so each row gets
1338	// vertical edges; bottom edge closes the box. Blank interior
1339	// rows after the top and before the bottom give the body a bit
1340	// of breathing room from the corners.
1341	color := v.Theme.ToolOut
1342	if tc.Error {
1343		color = v.Theme.Error
1344	}
1345	if v.FlatTools || v.CompactMode {
1346		if v.CompactMode {
1347			lines = append(lines, compactToolBlank(v.Theme, width))
1348		}
1349		lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1350		body := v.renderLiveToolResult(bodyText, flatToolBodyRenderWidth(width), color, tc.LivePath)
1351		for _, l := range v.collapseToolBody(body, false) {
1352			_, stripped := parseImageFootprint(l)
1353			lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1354		}
1355		if v.CompactMode {
1356			lines = append(lines, compactToolBlank(v.Theme, width))
1357		}
1358		return lines
1359	}
1360	lines = append(lines, toolBoxTop(v.Theme, label, width))
1361	lines = append(lines, toolBoxSide(v.Theme, "", width))
1362	body := v.renderLiveToolResult(bodyText, toolBoxBodyRenderWidth(width), color, tc.LivePath)
1363	for _, l := range v.collapseToolBody(body, false) {
1364		imgCells, stripped := parseImageFootprint(l)
1365		if hasImageEscapeLine(stripped) {
1366			lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1367			continue
1368		}
1369		lines = append(lines, toolBoxSide(v.Theme, stripped, width))
1370	}
1371	lines = append(lines, toolBoxSide(v.Theme, "", width))
1372	lines = append(lines, toolBoxBottom(v.Theme, width))
1373	return lines
1374}
1375
1376// renderLiveToolResult uses the transcript's text renderer so every rich
1377// confirmation preview immediately gets the same colors, numbered gutters,
1378// and syntax highlighting as its final tool result.
1379func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1380	full := v.renderToolText(text, width, color, sourcePath, 1)
1381	if v.ExpandAll || len(full) <= ToolCollapseLines {
1382		return full
1383	}
1384	return v.collapseToolBodyWithTotal(full, false, len(full))
1385}
1386
1387// renderLiveToolBody renders the in-flight preview of a streaming
1388// tool call. Supported tools:
1389//
1390//   - write: shows the partial `content` field, syntax-highlighted
1391//     by the target path's language.
1392//   - edit:  shows the partial `newText` of the edit currently being
1393//     streamed, prefixed with a "editing foo.ts (edit 2)" header so
1394//     the user can see which of a multi-edit batch is in progress.
1395//   - bash:  shows the full command as soon as the JSON args are known,
1396//     before stdout/stderr has arrived, so long-running commands are legible.
1397//
1398// Anything else returns nil and only the tool-call header shows.
1399func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
1400	switch tc.Name {
1401	case "write", "Write":
1402		partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
1403		if !ok || partial == "" {
1404			return nil
1405		}
1406		return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
1407	case "edit", "Edit":
1408		partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
1409		if !ok || partial == "" {
1410			return nil
1411		}
1412		// Header line hints which edit is streaming and, when more
1413		// than one has landed, how many the model is doing.
1414		hint := fmt.Sprintf("edit %d (streaming)", idx)
1415		body := []string{"    " + v.Theme.FGColor(v.Theme.Muted, hint), ""}
1416		body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1417		return v.wrapLiveBody(body, width)
1418	case "bash", "Bash":
1419		command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
1420		if !ok || strings.TrimSpace(command) == "" {
1421			return nil
1422		}
1423		return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
1424	}
1425	return nil
1426}
1427
1428func (v *View) renderLiveBashCommand(command string, width int) []string {
1429	inner := toolBoxBodyRenderWidth(width)
1430	if v.FlatTools || v.CompactMode {
1431		inner = flatToolBodyRenderWidth(width)
1432	}
1433	prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
1434	var out []string
1435	for i, line := range strings.Split(command, "\n") {
1436		firstPrefix := "    "
1437		if i == 0 {
1438			firstPrefix += prompt
1439		} else {
1440			firstPrefix += "  "
1441		}
1442		contPrefix := "      "
1443		wrapWidth := inner - visibleWidth(firstPrefix)
1444		if wrapWidth < 10 {
1445			wrapWidth = 10
1446		}
1447		for j, wrapped := range wrapANSILine(v.Theme.FGColor(v.Theme.ToolOut, line), wrapWidth) {
1448			prefix := firstPrefix
1449			if j > 0 {
1450				prefix = contPrefix
1451			}
1452			out = append(out, prefix+wrapped)
1453		}
1454	}
1455	return out
1456}
1457
1458// wrapLiveBody returns the streaming body content as a list of
1459// box-side rows: each line wrapped in │ ... │ with right padding so
1460// the closing edge sits at column width-1. The caller (renderToolCall)
1461// supplies the surrounding top/bottom edges so the live overlay
1462// renders as a closed box matching the finalised transcript form.
1463func (v *View) wrapLiveBody(body []string, width int) []string {
1464	body = v.collapseToolBody(body, false)
1465	out := make([]string, 0, len(body))
1466	if v.FlatTools || v.CompactMode {
1467		for _, l := range body {
1468			_, stripped := parseImageFootprint(l)
1469			out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1470		}
1471		return out
1472	}
1473	for _, l := range body {
1474		imgCells, stripped := parseImageFootprint(l)
1475		if hasImageEscapeLine(stripped) {
1476			out = append(out, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1477			continue
1478		}
1479		out = append(out, toolBoxSide(v.Theme, stripped, width))
1480	}
1481	return out
1482}
1483
1484// toolBoxInnerPad is the number of blank cells kept between a box
1485// edge (┌, │, └) and the content inside it. One cell of breathing
1486// room makes the label read as part of the box rather than fused to
1487// the corner, and gives body lines a tiny gutter from the left side.
1488const toolBoxInnerPad = 1
1489
1490// toolBoxOuterMargin is the number of blank cells kept to the left of
1491// the opening corner and to the right of the closing corner. Aligns
1492// the box's frame with the column where user-bubble text begins, so
1493// the conversation reads as one column instead of having tool boxes
1494// running edge-to-edge while user/assistant rows sit indented.
1495const toolBoxOuterMargin = 2
1496
1497// toolBoxTop renders the labelled top edge of a tool block:
1498//
1499//	┌─  bash xcrun simctl list devices ─────────────────────────────┐
1500//
1501// The label is the tool name + short args (the same string previously
1502// shown on its own row beneath the rule). Padding fills the remainder
1503// of the line so the right corner sits at column width-1, matching the
1504// closing edge.
1505func toolBoxTop(th Theme, label string, width int) string {
1506	label = oneLineToolLabel(label)
1507	w := width - 2*toolBoxOuterMargin
1508	if w < 12 {
1509		w = 12
1510	}
1511	margin := strings.Repeat(" ", toolBoxOuterMargin)
1512	innerPad := strings.Repeat(" ", toolBoxInnerPad)
1513	// "┌─" + innerPad + " " + label + " " + innerPad = used; pad
1514	// with ─ to width-1, then "┐".
1515	prefix := "┌─" + innerPad + " "
1516	suffix := " " + innerPad
1517	used := visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1518	fill := w - used - 1 // -1 for the closing corner
1519	if fill < 0 {
1520		// Label overflows; truncate with an ellipsis so the right
1521		// corner still lands on the right edge. Rare on real
1522		// terminals (the chat column is usually wide enough).
1523		over := -fill
1524		runes := []rune(label)
1525		if over+3 < len(runes) {
1526			label = string(runes[:len(runes)-over-3]) + "..."
1527		} else {
1528			label = "..."
1529		}
1530		used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1531		fill = w - used - 1
1532		if fill < 0 {
1533			fill = 0
1534		}
1535	}
1536	fillStr := strings.Repeat("─", fill)
1537	name, rest := splitToolLabel(label)
1538	return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
1539}
1540
1541func oneLineToolLabel(label string) string {
1542	return strings.Join(strings.Fields(label), " ")
1543}
1544
1545func splitToolLabel(label string) (name, rest string) {
1546	label = strings.TrimLeft(label, " ")
1547	if label == "" {
1548		return "", ""
1549	}
1550	idx := strings.IndexAny(label, " \t")
1551	if idx < 0 {
1552		return label, ""
1553	}
1554	return label[:idx], label[idx:]
1555}
1556
1557// flatToolHeader renders the boxless header line for a tool call:
1558//
1559//	▌ bash xcrun simctl list devices
1560//
1561// It carries the same information as toolBoxTop (tool name + short
1562// args) but drops the frame: a single accent gutter glyph, the tool
1563// name in the foreground color, and the argument summary muted. The
1564// line is left-aligned at the same column the box's opening corner
1565// used (toolBoxOuterMargin) so flat and box renders share a column.
1566func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1567	if compact {
1568		return compactToolHeader(th, label, width)
1569	}
1570	return flatToolHeader(th, label, width)
1571}
1572
1573func flatToolHeader(th Theme, label string, width int) string {
1574	label = oneLineToolLabel(label)
1575	name, rest := splitToolLabel(label)
1576	margin := strings.Repeat(" ", toolBoxOuterMargin)
1577	gutter := th.FGColor(th.Assistant, "▌") + " "
1578	// Budget the visible text to the content width so a very long
1579	// argument summary doesn't run off the right edge; truncate with
1580	// an ellipsis like the box header does.
1581	avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
1582	if avail < 12 {
1583		avail = 12
1584	}
1585	if visibleWidth(name+rest) > avail {
1586		over := visibleWidth(name+rest) - avail
1587		runes := []rune(rest)
1588		if over+3 < len(runes) {
1589			rest = string(runes[:len(runes)-over-3]) + "..."
1590		} else if visibleWidth(name) <= avail {
1591			rest = ""
1592		}
1593	}
1594	return margin + gutter + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest)
1595}
1596
1597func compactToolBlank(th Theme, width int) string {
1598	if width < 1 {
1599		width = 1
1600	}
1601	return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1602}
1603
1604func compactToolHeader(th Theme, label string, width int) string {
1605	label = oneLineToolLabel(label)
1606	name, rest := splitToolLabel(label)
1607	pad := "  "
1608	avail := width - visibleWidth(pad)*2
1609	if avail < 12 {
1610		avail = 12
1611	}
1612	if visibleWidth(name+rest) > avail {
1613		over := visibleWidth(name+rest) - avail
1614		runes := []rune(rest)
1615		if over+3 < len(runes) {
1616			rest = string(runes[:len(runes)-over-3]) + "..."
1617		} else if visibleWidth(name) <= avail {
1618			rest = ""
1619		}
1620	}
1621	plain := pad + name + rest
1622	visible := visibleWidth(plain)
1623	fill := ""
1624	if visible < width {
1625		fill = strings.Repeat(" ", width-visible)
1626	}
1627	bg := th.sgrBGColor(th.UserBubbleBG)
1628	return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset
1629}
1630
1631func toolBodyLine(th Theme, line string, width int, compact bool) string {
1632	line = flatToolBody(th, line)
1633	if compact {
1634		return compactToolBody(th, line, width)
1635	}
1636	return line
1637}
1638
1639// flatToolBody indents a single body line for boxless rendering. It
1640// trims the same leading padding box bodies do, then prepends a fixed
1641// gutter indent so output sits in a readable column under the header
1642// without any vertical frame. ANSI styling in the line is preserved.
1643func flatToolBody(th Theme, line string) string {
1644	_ = th
1645	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1646	return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1647}
1648
1649func compactToolBody(th Theme, line string, width int) string {
1650	visible := visibleWidth(line)
1651	fill := ""
1652	if visible < width {
1653		fill = strings.Repeat(" ", width-visible)
1654	}
1655	bg := th.sgrBGColor(th.UserBubbleBG)
1656	return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1657}
1658
1659// toolBoxBottom renders the bottom edge of a tool block:
1660//
1661//	└─────────────────────────────────────────────────────────────────────────────┘
1662//
1663// Spans the same width as toolBoxTop so the corners line up.
1664func toolBoxBottom(th Theme, width int) string {
1665	w := width - 2*toolBoxOuterMargin
1666	if w < 12 {
1667		w = 12
1668	}
1669	margin := strings.Repeat(" ", toolBoxOuterMargin)
1670	line := "└" + strings.Repeat("─", w-2) + "┘"
1671	return margin + th.FGColor(th.Muted, line) + margin
1672}
1673
1674// hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
1675// iTerm2 (\x1b]1337;File=) inline-image escape. Such rows draw into
1676// the terminal's graphics layer and shouldn't be wrapped with text
1677// box edges.
1678func hasImageEscapeLine(s string) bool {
1679	return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
1680}
1681
1682// imageFootprintSentinel marks rows that belong to an inline-image's
1683// reserved footprint — the escape row plus the blank rows below it
1684// plus the gap row before the metadata caption. Any consumer that
1685// wraps content in box edges (│ ... │) detects the sentinel, strips
1686// it, and emits the row — the image graphics rectangle paints over
1687// whatever was drawn there. Uses a non-printing C0 control byte so
1688// it can never appear in normal text or in an ANSI escape sequence
1689// body.
1690//
1691// The escape row carries an extra width hint between two sentinels:
1692// "\x1e<cells>\x1e<spaces+escape>". Wrappers parse the cells count
1693// to know how wide the image is in terminal cells and how many cells
1694// of trailing padding are needed before the closing box edge.
1695const imageFootprintSentinel = "\x1e"
1696
1697// parseImageFootprint splits a footprint-tagged row into (cellsWide,
1698// rest). cellsWide is 0 when the row carries no width hint (the
1699// reservation blanks and gap row don't, only the escape row does).
1700func parseImageFootprint(s string) (int, string) {
1701	if !strings.HasPrefix(s, imageFootprintSentinel) {
1702		return 0, s
1703	}
1704	rest := s[len(imageFootprintSentinel):]
1705	end := strings.Index(rest, imageFootprintSentinel)
1706	if end < 0 {
1707		return 0, rest
1708	}
1709	w, err := strconv.Atoi(rest[:end])
1710	if err != nil {
1711		return 0, rest
1712	}
1713	return w, rest[end+len(imageFootprintSentinel):]
1714}
1715
1716// toolBoxBodyTrimLeft is the number of leading literal spaces stripped
1717// from each body line as it enters toolBoxSide. Body renderers
1718// (renderToolText, renderRawFile, the diff/bash helpers, ...) all
1719// emit a 4-cell indent so the content aligns with the assistant body
1720// column when there's no box around it. Inside a box the box edge +
1721// inner pad already supply most of that gutter, so we drop a couple
1722// of cells from the body's own indent to keep the content from
1723// drifting too far right.
1724const toolBoxBodyTrimLeft = 2
1725
1726// toolBoxBodyRenderWidth is the width body renderers should target before
1727// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
1728// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
1729// then adds the box edge/padding. Passing the full terminal width lets long
1730// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
1731// visually breaking the right edge. This returns the maximum renderer width
1732// whose post-trim visible width fits inside the box.
1733func toolBoxBodyRenderWidth(width int) int {
1734	w := width - 2*toolBoxOuterMargin
1735	if w < 12 {
1736		w = 12
1737	}
1738	inner := w - 2 - 2*toolBoxInnerPad
1739	if inner < 1 {
1740		inner = 1
1741	}
1742	return inner + toolBoxBodyTrimLeft
1743}
1744
1745// flatToolBodyRenderWidth is the width body renderers should target in
1746// flat mode. There's no box frame, just the flatToolBody indent
1747// (toolBoxOuterMargin+2 cells), so the renderable width is the
1748// terminal width minus that indent. Renderers emit a 4-cell indent of
1749// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
1750// so add that back like toolBoxBodyRenderWidth does.
1751func flatToolBodyRenderWidth(width int) int {
1752	inner := width - (toolBoxOuterMargin + 2)
1753	if inner < 1 {
1754		inner = 1
1755	}
1756	return inner + toolBoxBodyTrimLeft
1757}
1758
1759// toolBoxSide wraps a single body line with vertical box edges:
1760//
1761//	│  foo bar baz                                            │
1762//
1763// The middle preserves the line verbatim (including any ANSI escapes)
1764// after stripping toolBoxBodyTrimLeft leading literal spaces. One cell
1765// of inner padding sits on each side so the content breathes from the
1766// edges; the right padding fills to column width-1.
1767func toolBoxSide(th Theme, line string, width int) string {
1768	w := width - 2*toolBoxOuterMargin
1769	if w < 12 {
1770		w = 12
1771	}
1772	margin := strings.Repeat(" ", toolBoxOuterMargin)
1773	mutedEdge := th.FGColor(th.Muted, "│")
1774	left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1775	right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1776	inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
1777	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1778
1779	cur := visibleWidth(line)
1780	if cur > inner {
1781		// Last-resort truncation must retain syntax foreground escapes.
1782		// Stripping ANSI here made long read results switch from their
1783		// configured syntax color to the terminal's default foreground
1784		// whenever the window became narrow enough to clip a line.
1785		line = truncateToWidth(line, inner)
1786		cur = visibleWidth(line)
1787	}
1788	pad := inner - cur
1789	if pad < 0 {
1790		pad = 0
1791	}
1792	return margin + left + line + strings.Repeat(" ", pad) + right + margin
1793}
1794
1795// toolBoxSideWithImage wraps a row that carries an inline-image
1796// escape sequence. The image protocols are configured to not move
1797// the cursor after rendering (iTerm: doNotMoveCursor=1, kitty/
1798// ghostty: C=1), so the only visible cell advance on the row is
1799// from the leading indent spaces before the escape. We pad from
1800// there to the right edge column and draw the closing │ there.
1801// imgCells is unused (kept in the signature for compatibility with
1802// the parseImageFootprint width hint).
1803func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
1804	_ = imgCells
1805	w := width - 2*toolBoxOuterMargin
1806	if w < 12 {
1807		w = 12
1808	}
1809	margin := strings.Repeat(" ", toolBoxOuterMargin)
1810	mutedEdge := th.FGColor(th.Muted, "│")
1811	left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1812	right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1813	inner := w - 2 - 2*toolBoxInnerPad
1814
1815	line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1816
1817	// Count leading spaces (visible cells consumed before the escape).
1818	leading := 0
1819	for leading < len(line) && line[leading] == ' ' {
1820		leading++
1821	}
1822
1823	pad := inner - leading
1824	if pad < 0 {
1825		pad = 0
1826	}
1827	return margin + left + line + strings.Repeat(" ", pad) + right + margin
1828}
1829
1830// trimLeadingSpaces removes up to n literal space characters from the
1831// start of s without touching anything else. ANSI CSI escapes that
1832// appear before the spaces are skipped over (so a coloured indent
1833// like "\x1b[2m    text" still loses its first n cells of indent).
1834// Stops as soon as it hits a non-space character.
1835func trimLeadingSpaces(s string, n int) string {
1836	if n <= 0 || s == "" {
1837		return s
1838	}
1839	var b strings.Builder
1840	b.Grow(len(s))
1841	trimmed := 0
1842	i := 0
1843	for i < len(s) {
1844		// Pass through any ANSI escape sequence verbatim.
1845		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1846			end := i + 2
1847			for end < len(s) {
1848				c := s[end]
1849				end++
1850				if c >= 0x40 && c <= 0x7e {
1851					break
1852				}
1853			}
1854			b.WriteString(s[i:end])
1855			i = end
1856			continue
1857		}
1858		if trimmed < n && s[i] == ' ' {
1859			trimmed++
1860			i++
1861			continue
1862		}
1863		b.WriteString(s[i:])
1864		return b.String()
1865	}
1866	return b.String()
1867}
1868
1869// renderToolResultContent renders the body of a tool result block.
1870// Text blocks get the usual rules-wrapped treatment; text that looks
1871// like a unified diff gets +/- coloring. Image blocks are rendered
1872// inline when the terminal supports a protocol, else as a text
1873// placeholder with dimensions.
1874func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string {
1875	var body []string
1876	hasImage := false
1877	for _, b := range blocks {
1878		if _, ok := b.(provider.ImageBlock); ok {
1879			hasImage = true
1880			break
1881		}
1882	}
1883	total := 0
1884	for _, b := range blocks {
1885		switch bb := b.(type) {
1886		case provider.TextBlock:
1887			bodyWidth := toolBoxBodyRenderWidth(width)
1888			if v.FlatTools || v.CompactMode {
1889				bodyWidth = flatToolBodyRenderWidth(width)
1890			}
1891			estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1892			estimated := estimateToolTextLines(bb.Text, estimateWidth)
1893			total += estimated
1894			text := bb.Text
1895			// A collapsed result only exposes its first ten rendered rows.
1896			// Render a bounded source preview in that case; syntax
1897			// highlighting and wrapping the omitted tail is wasted work.
1898			if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1899				text = previewToolText(text, estimateWidth)
1900			}
1901			body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
1902		case provider.ImageBlock:
1903			body = append(body, v.renderImageBlock(bb, width)...)
1904		}
1905	}
1906	return v.collapseToolBodyWithTotal(body, hasImage, total)
1907}
1908
1909// collapseToolBody trims lines to the configured preview size when the
1910// view is not in ExpandAll mode, appending a muted "... ctrl+o to
1911// expand" footer. Image blocks never collapse — they're short in text
1912// rows but represent real content the user wants to see.
1913func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
1914	return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
1915}
1916
1917func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
1918	if v.ExpandAll || hasImage {
1919		return lines
1920	}
1921	if total < len(lines) {
1922		total = len(lines)
1923	}
1924	if total <= ToolCollapseLines {
1925		return lines
1926	}
1927	kept := lines
1928	if len(kept) > ToolCollapsePreview {
1929		kept = kept[:ToolCollapsePreview]
1930	}
1931	hidden := total - ToolCollapsePreview
1932	footer := fmt.Sprintf("    ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
1933	footer = v.Theme.FGColor(v.Theme.Muted, footer)
1934	out := append([]string(nil), kept...)
1935	out = append(out, "")
1936	return append(out, footer)
1937}
1938
1939func effectiveToolTextWidth(text string, width int) int {
1940	// renderToolText prefixes ordinary rows with a four-cell gutter before
1941	// wrapping them. Diff rows also carry a five-cell read gutter. Estimating
1942	// against the outer body width undercounts long rows and produces an
1943	// incorrect collapse total.
1944	width -= 4
1945	if looksLikeUnifiedDiff(text) {
1946		width -= 5
1947	}
1948	if width < 1 {
1949		return 1
1950	}
1951	return width
1952}
1953
1954func estimateToolTextLines(text string, width int) int {
1955	if width < 1 {
1956		width = 1
1957	}
1958	total := 0
1959	for _, line := range strings.Split(text, "\n") {
1960		cells := runewidth.StringWidth(line)
1961		if cells == 0 {
1962			total++
1963			continue
1964		}
1965		total += (cells + width - 1) / width
1966	}
1967	if total == 0 {
1968		return 1
1969	}
1970	return total
1971}
1972
1973func previewToolText(text string, width int) string {
1974	if width < 1 {
1975		width = 1
1976	}
1977	maxChars := width * (ToolCollapsePreview + 2)
1978	if maxChars < 256 {
1979		maxChars = 256
1980	}
1981	if maxChars > 8192 {
1982		maxChars = 8192
1983	}
1984	end := 0
1985	for count := 0; end < len(text) && count < maxChars; count++ {
1986		_, size := utf8.DecodeRuneInString(text[end:])
1987		if size == 0 {
1988			break
1989		}
1990		end += size
1991	}
1992	preview := text[:end]
1993	lines := strings.SplitAfter(preview, "\n")
1994	if len(lines) > ToolCollapsePreview+2 {
1995		preview = strings.Join(lines[:ToolCollapsePreview+2], "")
1996	}
1997	return preview
1998}
1999
2000// renderToolText renders a text block inside a tool result. If the
2001// text contains a unified-diff section (lines starting with "--- " /
2002// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
2003// colors matching git diff conventions.
2004func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string {
2005	// Legacy path: transcripts saved before we dropped line numbers
2006	// from the read tool still carry "     1\t..." prefixes. Detect and
2007	// strip them, then fall through to the highlighter.
2008	if looksLikeNumberedFile(text) {
2009		return v.renderNumberedFile(text, sourcePath)
2010	}
2011	// If the result embeds a unified diff (the edit tool's output
2012	// starts with a short "applied N edit(s)" line and then a
2013	// standard --- / +++ / +/- patch), render the patch with
2014	// add/remove coloring. This takes priority over the file-like
2015	// detector below because a diff technically has many lines of
2016	// "file content" but what the user cares about is what changed,
2017	// not a dump of the post-edit file.
2018	if looksLikeUnifiedDiff(text) {
2019		return v.renderUnifiedDiff(text, width, sourcePath)
2020	}
2021
2022	// Current path: text came from `read` as raw file bytes. When a
2023	// source path is known (the call had a `path` arg), render with
2024	// a synthetic line-number gutter starting at startLine so the
2025	// on-screen view still looks like cat -n. Doesn't apply to non-
2026	// file tool outputs (bash stdout, display notes, etc.).
2027	if sourcePath != "" && looksLikeFileContent(text) {
2028		return v.renderRawFile(text, sourcePath, startLine)
2029	}
2030
2031	// No truncation — the full tool output is rendered into chat and
2032	// becomes part of the scrollback you can page back through.
2033	lines := strings.Split(text, "\n")
2034
2035	// Bash-result styling: when the first row looks like a shell
2036	// prompt line ("$ ...") emitted by the bash tool, style the
2037	// prompt line in accent and the trailing "[exit N]  Took X.Ys"
2038	// line in muted type so the command + timing read at a glance.
2039	// Everything between is left on the default tool-output color.
2040	if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
2041		return v.renderBashResult(lines, width, defaultColor)
2042	}
2043
2044	inDiff := false
2045	oldLine, newLine := 1, 1
2046	var out []string
2047	for _, l := range lines {
2048		// Detect diff header: "--- name" followed somewhere by "+++ name".
2049		if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
2050			inDiff = true
2051			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, l))
2052			continue
2053		}
2054		// Hunk header "@@ -a,b +c,d @@" resets the counters so patches
2055		// that skip around in the file still get correct numbering.
2056		if inDiff && strings.HasPrefix(l, "@@") {
2057			if o, n, ok := parseHunkHeader(l); ok {
2058				oldLine, newLine = o, n
2059			}
2060			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, l))
2061			continue
2062		}
2063		if inDiff && strings.TrimSpace(l) == "..." {
2064			out = append(out, "")
2065			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, "..."))
2066			continue
2067		}
2068		if inDiff && len(l) > 0 {
2069			switch l[0] {
2070			case '+':
2071				out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2072				newLine++
2073				continue
2074			case '-':
2075				out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2076				oldLine++
2077				continue
2078			case ' ':
2079				out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2080				oldLine++
2081				newLine++
2082				continue
2083			}
2084		}
2085		// Regular line.
2086		for _, w := range wrapLine(l, width-4, "    ") {
2087			out = append(out, "    "+v.Theme.FGColor(defaultColor, w))
2088		}
2089	}
2090	return out
2091}
2092
2093// parseHunkHeader extracts the starting old/new line from a unified
2094// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
2095// header is malformed or missing numbers.
2096func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
2097	// Skip "@@ "
2098	rest := strings.TrimPrefix(l, "@@")
2099	rest = strings.TrimSpace(rest)
2100	if !strings.HasPrefix(rest, "-") {
2101		return 0, 0, false
2102	}
2103	rest = rest[1:]
2104	space := strings.IndexByte(rest, ' ')
2105	if space < 0 {
2106		return 0, 0, false
2107	}
2108	oldPart := rest[:space]
2109	rest = strings.TrimSpace(rest[space+1:])
2110	if !strings.HasPrefix(rest, "+") {
2111		return 0, 0, false
2112	}
2113	rest = rest[1:]
2114	if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
2115		rest = rest[:sp]
2116	}
2117	parseStart := func(s string) (int, bool) {
2118		if c := strings.IndexByte(s, ','); c >= 0 {
2119			s = s[:c]
2120		}
2121		n, err := strconv.Atoi(s)
2122		if err != nil || n < 1 {
2123			return 0, false
2124		}
2125		return n, true
2126	}
2127	o, ok1 := parseStart(oldPart)
2128	n, ok2 := parseStart(rest)
2129	if !ok1 || !ok2 {
2130		return 0, 0, false
2131	}
2132	return o, n, true
2133}
2134
2135// renderDiffRow renders one unified-diff source line as one or more visual
2136// rows. The first row carries the read-style line-number gutter; continuation
2137// rows use a blank gutter so wrapped content cannot be mistaken for another
2138// source line. Code is syntax-highlighted if sourcePath hints at a known
2139// language and otherwise uses the diff color.
2140func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string {
2141	if len(line) == 0 {
2142		return []string{""}
2143	}
2144	code := expandTabs(line[1:]) // strip the leading marker; expand tabs
2145
2146	// Gutter shape: sign + number share a color so they read as one
2147	// visual token ("+123") instead of a neutral line number next to
2148	// a stray marker. Unchanged context lines get a muted gutter and
2149	// a leading space so column alignment stays consistent with +/-
2150	// rows.
2151	var gutterText string
2152	switch mark {
2153	case '+':
2154		gutterText = fmt.Sprintf("+%3d ", lineNo)
2155	case '-':
2156		gutterText = fmt.Sprintf("-%3d ", lineNo)
2157	default:
2158		gutterText = fmt.Sprintf(" %3d ", lineNo)
2159	}
2160	gutterColor := color
2161	if mark == ' ' {
2162		gutterColor = v.Theme.Muted
2163	}
2164
2165	maxCode := width - visibleWidth("    "+gutterText)
2166	if maxCode < 1 {
2167		maxCode = 1
2168	}
2169	chunks := wrapCodeLine(code, maxCode)
2170	lang := LanguageFromPath(sourcePath)
2171	out := make([]string, 0, len(chunks))
2172	for i, chunk := range chunks {
2173		rowGutter := gutterText
2174		if i > 0 {
2175			rowGutter = strings.Repeat(" ", visibleWidth(gutterText))
2176		}
2177		codeRendered := ""
2178		if lang != "" {
2179			if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 {
2180				codeRendered = h[0]
2181			}
2182		}
2183		if codeRendered == "" {
2184			codeColor := color
2185			if mark == ' ' {
2186				codeColor = v.Theme.Muted
2187			}
2188			codeRendered = v.Theme.FGColor(codeColor, chunk)
2189		}
2190		out = append(out, "    "+v.Theme.FGColor(gutterColor, rowGutter)+codeRendered)
2191	}
2192	return out
2193}
2194
2195// wrapCodeLine splits code to a terminal cell width without dropping any
2196// characters. It prefers breaking after whitespace, then falls back to a hard
2197// rune boundary for long tokens. Keeping the whitespace on the preceding row
2198// means joining the returned chunks reconstructs the expanded source exactly.
2199func wrapCodeLine(line string, width int) []string {
2200	if width < 1 || visibleWidth(line) <= width {
2201		return []string{line}
2202	}
2203	runes := []rune(line)
2204	var out []string
2205	for start := 0; start < len(runes); {
2206		cells := 0
2207		end := start
2208		lastBreak := -1
2209		for end < len(runes) {
2210			runeWidth := runewidth.RuneWidth(runes[end])
2211			if cells+runeWidth > width && end > start {
2212				break
2213			}
2214			cells += runeWidth
2215			end++
2216			if runes[end-1] == ' ' || runes[end-1] == '\t' {
2217				lastBreak = end
2218			}
2219			if cells >= width {
2220				break
2221			}
2222		}
2223		if end == start { // A rune wider than the requested width.
2224			end++
2225		}
2226		if end < len(runes) && lastBreak > start {
2227			end = lastBreak
2228		}
2229		out = append(out, string(runes[start:end]))
2230		start = end
2231	}
2232	return out
2233}
2234
2235// renderImageBlock returns the lines for one image, inline if possible.
2236//
2237// Inline image escapes paint into multiple terminal rows but the zut
2238// renderer treats each slice entry as a single row. To prevent chat
2239// content from being drawn on top of the image, we pad with blank rows
2240// so the image's real footprint is reflected in the frame height.
2241func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
2242	w, h := ImageDimensions(b.Data)
2243	kb := len(b.Data) / 1024
2244	// Four-space indent matches every other tool-output row
2245	// (renderRawFile, renderBashResult, diff rows, "... N more
2246	// lines" footers). Keeps the image's metadata line vertically
2247	// aligned with the file content above / below it.
2248	info := fmt.Sprintf("    image - %s - %dx%d - %d KB", b.MimeType, w, h, kb)
2249
2250	if v.ImageProto != ImageProtocolNone {
2251		// Clamp rendered width so the image never overflows the chat
2252		// column. Subtract a 4-cell indent for the tool result block,
2253		// cap at 60 cells, floor at 10.
2254		cells := width - 4
2255		if cells > 60 {
2256			cells = 60
2257		}
2258		if cells < 10 {
2259			cells = 10
2260		}
2261		const maxRows = 20
2262		if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
2263			rows, actualCells := InlineImageFootprint(b.Data, cells, maxRows)
2264			if rows < 1 {
2265				rows = 1
2266			}
2267			if actualCells < 1 {
2268				actualCells = cells
2269			}
2270			// Reserve the image footprint first, then place the escape
2271			// in the top-left of that blank rectangle. Keeping the
2272			// metadata above the image made some terminals blend text
2273			// and graphics layers during full-screen repaints; metadata
2274			// below the reserved rectangle is more stable and easier to
2275			// read. One extra blank row before the metadata gives the
2276			// caption breathing room from the image's last pixel row.
2277			//
2278			// Every footprint row (escape, reservations, gap) is tagged
2279			// with imageFootprintSentinel so callers wrapping content in
2280			// box edges can recognise these rows and emit them bare —
2281			// the image's graphics rectangle paints over them and any
2282			// │ character drawn alongside would visibly bleed through.
2283			// 4 leading cells push the escape past the box's left
2284			// edge plus a small interior gutter so the image rectangle
2285			// sits visibly inside the frame instead of kissing the │.
2286			// The escape row carries a width hint after the sentinel
2287			// ("\x1e<cells>\x1e...") so toolBoxSide knows how many
2288			// cells the image occupies and can pad to the right edge.
2289			widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)
2290			out := make([]string, 0, rows+3)
2291			out = append(out, widthHint+"    "+seq)
2292			for i := 1; i < rows; i++ {
2293				out = append(out, imageFootprintSentinel)
2294			}
2295			out = append(out, imageFootprintSentinel)
2296			out = append(out, v.Theme.FGColor(v.Theme.Muted, info))
2297			return out
2298		}
2299	}
2300	return []string{v.Theme.FGColor(v.Theme.Muted, info)}
2301}
2302
2303// looksLikeNumberedFile returns true when text matches the `read`
2304// tool's "     N\tcontent" format for most of its lines.
2305func looksLikeNumberedFile(text string) bool {
2306	lines := strings.Split(text, "\n")
2307	if len(lines) == 0 {
2308		return false
2309	}
2310	hits := 0
2311	scanned := 0
2312	for _, l := range lines {
2313		if l == "" {
2314			continue
2315		}
2316		scanned++
2317		if scanned > 20 {
2318			break
2319		}
2320		if numberedLineRE.MatchString(l) {
2321			hits++
2322		}
2323	}
2324	if scanned == 0 {
2325		return false
2326	}
2327	return hits*2 >= scanned // majority of non-empty lines match
2328}
2329
2330var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
2331
2332// renderNumberedFile strips line numbers, highlights the code, and
2333// re-attaches the line numbers in muted color.
2334func (v *View) renderNumberedFile(text, sourcePath string) []string {
2335	lines := strings.Split(text, "\n")
2336	gutters := make([]string, 0, len(lines))
2337	codes := make([]string, 0, len(lines))
2338	for _, l := range lines {
2339		idx := strings.IndexByte(l, '\t')
2340		if idx < 0 || !numberedLineRE.MatchString(l) {
2341			// Non-code footer (e.g. "[truncated at 2000 lines]").
2342			gutters = append(gutters, "")
2343			codes = append(codes, l)
2344			continue
2345		}
2346		gutter := l[:idx] + " " // replace tab with single space
2347		code := expandTabs(l[idx+1:])
2348		gutters = append(gutters, gutter)
2349		codes = append(codes, code)
2350	}
2351	lang := LanguageFromPath(sourcePath)
2352	var highlighted []string
2353	if lang != "" {
2354		highlighted = v.Theme.HighlightCode(strings.Join(codes, "\n"), lang)
2355		// Chroma sometimes collapses the trailing empty line. Pad to
2356		// align with the gutter slice so per-line zipping works.
2357		for len(highlighted) < len(codes) {
2358			highlighted = append(highlighted, "")
2359		}
2360		if len(highlighted) > len(codes) {
2361			highlighted = highlighted[:len(codes)]
2362		}
2363	} else {
2364		// No lexer for this file type — render in the ToolOut color so
2365		// code is visually distinct from the muted gutter.
2366		highlighted = make([]string, len(codes))
2367		for i, c := range codes {
2368			highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2369		}
2370	}
2371	out := make([]string, 0, len(codes))
2372	for i, code := range highlighted {
2373		g := gutters[i]
2374		if g == "" {
2375			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, code))
2376			continue
2377		}
2378		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, g)+code)
2379	}
2380	return out
2381}
2382
2383// looksLikeFileContent is a cheap guard to distinguish a read-tool
2384// result from bash stdout or a status message. File content usually
2385// contains characters that status messages don't (code punctuation,
2386// longer lines, multiple lines) and rarely starts with the "  >"-
2387// or "error:"-style prefixes tools emit. False positives are OK,
2388// the worst case is a line-number gutter on something that isn't
2389// really code.
2390// renderBashResult styles a bash tool result: the "$ command" first
2391// line in the accent color, the trailing "[exit N]  Took X.Ys" line
2392// in muted type, everything else on the default tool-output color.
2393// Called from renderToolText when the first line starts with "$ ".
2394func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string {
2395	lines = normalizeBashOutputLines(lines)
2396	// Identify the footer line (exit + timing). The bash tool writes
2397	// it as the last non-empty line of the result.
2398	footerIdx := -1
2399	for i := len(lines) - 1; i >= 0; i-- {
2400		trimmed := strings.TrimSpace(lines[i])
2401		if trimmed == "" {
2402			continue
2403		}
2404		if strings.HasPrefix(trimmed, "[exit ") {
2405			footerIdx = i
2406		}
2407		break
2408	}
2409
2410	var out []string
2411	for i, l := range lines {
2412		switch {
2413		case i == 0 && strings.HasPrefix(l, "$ "):
2414			// Style the $ and the command text in accent. No
2415			// further processing / wrapping so the shell-style
2416			// prompt reads at a glance.
2417			for _, w := range wrapLine(l, width-4, "    ") {
2418				out = append(out, "    "+v.Theme.FGColor(v.Theme.Accent, w))
2419			}
2420		case i == footerIdx:
2421			for _, w := range wrapLine(l, width-4, "    ") {
2422				out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, w))
2423			}
2424		default:
2425			for _, w := range wrapLine(l, width-4, "    ") {
2426				out = append(out, "    "+v.Theme.FGColor(defaultColor, w))
2427			}
2428		}
2429	}
2430	return out
2431}
2432
2433// normalizeBashOutputLines turns arbitrary terminal output into plain,
2434// box-safe rows before wrapping. Unlike read/write/edit output, bash
2435// stdout/stderr may contain tabs, carriage returns, ANSI/OSC escapes,
2436// and other C0 controls from subprocesses. If those reach the bordered
2437// tool box, the width calculator can undercount what the terminal will
2438// draw and the right edge appears broken. Keep printable text, expand
2439// tabs to spaces, split carriage-return progress rows, and drop escape
2440// / control sequences.
2441func normalizeBashOutputLines(lines []string) []string {
2442	var out []string
2443	for _, line := range lines {
2444		for _, part := range strings.Split(strings.ReplaceAll(line, "\r", "\n"), "\n") {
2445			out = append(out, normalizeBashOutputLine(part))
2446		}
2447	}
2448	if len(out) == 0 {
2449		return []string{""}
2450	}
2451	return out
2452}
2453
2454func normalizeBashOutputLine(s string) string {
2455	var b strings.Builder
2456	col := 0
2457	for i := 0; i < len(s); {
2458		c := s[i]
2459		if c == 0x1b { // ESC: strip CSI/OSC/DCS and simple escapes.
2460			i = skipEscapeSequence(s, i)
2461			continue
2462		}
2463		r, size := utf8.DecodeRuneInString(s[i:])
2464		if r == utf8.RuneError && size == 1 {
2465			i++
2466			continue
2467		}
2468		switch r {
2469		case '\t':
2470			spaces := 8 - (col % 8)
2471			if spaces == 0 {
2472				spaces = 8
2473			}
2474			b.WriteString(strings.Repeat(" ", spaces))
2475			col += spaces
2476		case '\b':
2477			// Backspace-overstrike output is common in spinners/progress bars.
2478			// Dropping it is safer than moving the TUI cursor backwards.
2479		case '\n', '\r':
2480			// Already split by normalizeBashOutputLines.
2481		default:
2482			if r < 0x20 || r == 0x7f {
2483				// Drop non-printing controls.
2484				break
2485			}
2486			b.WriteRune(r)
2487			col += runewidth.RuneWidth(r)
2488		}
2489		i += size
2490	}
2491	return b.String()
2492}
2493
2494func skipEscapeSequence(s string, i int) int {
2495	if i >= len(s) || s[i] != 0x1b {
2496		return i + 1
2497	}
2498	if i+1 >= len(s) {
2499		return len(s)
2500	}
2501	switch s[i+1] {
2502	case '[': // CSI: ESC [ ... final byte 0x40-0x7e
2503		j := i + 2
2504		for j < len(s) {
2505			c := s[j]
2506			j++
2507			if c >= 0x40 && c <= 0x7e {
2508				break
2509			}
2510		}
2511		return j
2512	case ']': // OSC: ESC ] ... BEL or ST
2513		return skipStringEscape(s, i+2)
2514	case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
2515		return skipStringEscape(s, i+2)
2516	default:
2517		// Two-byte escape (cursor save/restore, charset select, etc.).
2518		return i + 2
2519	}
2520}
2521
2522func skipStringEscape(s string, i int) int {
2523	for i < len(s) {
2524		if s[i] == 0x07 { // BEL
2525			return i + 1
2526		}
2527		if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
2528			return i + 2
2529		}
2530		i++
2531	}
2532	return len(s)
2533}
2534
2535// looksLikeUnifiedDiff reports whether text is a context diff as
2536// emitted by the edit tool: rows start with '+', '-', ' ', or
2537// literal "..." (context-break marker). The presence of at least
2538// one '+' or '-' row distinguishes a real diff from an ordinary
2539// file whose lines happen to begin with a space.
2540func looksLikeUnifiedDiff(text string) bool {
2541	lines := strings.Split(text, "\n")
2542	if len(lines) < 2 {
2543		return false
2544	}
2545	sawChange := false
2546	for _, l := range lines {
2547		if l == "" {
2548			continue
2549		}
2550		if l == "..." {
2551			continue
2552		}
2553		switch l[0] {
2554		case '+', '-':
2555			sawChange = true
2556		case ' ':
2557			// context, ok
2558		default:
2559			return false
2560		}
2561	}
2562	return sawChange
2563}
2564
2565// renderUnifiedDiff renders the edit tool's context diff. Each
2566// kept row shows a line-number gutter plus a marker column: '+'
2567// for additions (colored like add), '-' for deletions (colored
2568// like remove), and unmarked context in muted type. A literal
2569// "..." line between hunks renders as an ellipsis in muted type,
2570// indicating skipped unchanged rows. The old and new line
2571// counters advance so each row carries its actual position in
2572// the pre- or post-edit file. Heuristic: when we hit a "...", we
2573// can't know where the next hunk starts, so we don't reset the
2574// counters — they stay approximate in the rare multi-hunk case.
2575func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
2576	lines := strings.Split(text, "\n")
2577	if n := len(lines); n > 0 && lines[n-1] == "" {
2578		lines = lines[:n-1]
2579	}
2580	oldLine, newLine := 1, 1
2581	var out []string
2582	for _, l := range lines {
2583		if l == "" {
2584			out = append(out, "")
2585			continue
2586		}
2587		if l == "..." {
2588			out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, "..."))
2589			continue
2590		}
2591		switch l[0] {
2592		case '+':
2593			out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2594			newLine++
2595		case '-':
2596			out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2597			oldLine++
2598		case ' ':
2599			out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2600			oldLine++
2601			newLine++
2602		default:
2603			for _, w := range wrapLine(l, width-4, "    ") {
2604				out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, w))
2605			}
2606		}
2607	}
2608	return out
2609}
2610
2611func looksLikeFileContent(text string) bool {
2612	if strings.TrimSpace(text) == "" {
2613		return false
2614	}
2615	lines := strings.Split(text, "\n")
2616	return len(lines) >= 2
2617}
2618
2619// renderRawFile renders file content received without embedded line
2620// numbers (the current read-tool output). Draws a muted gutter like
2621// "%6d \t" starting at startLine, highlights the code using the
2622// source path's language, and returns the formatted lines.
2623func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
2624	lines := strings.Split(text, "\n")
2625	// Drop the trailing empty line that Split produces when text ends
2626	// in "\n" so the gutter doesn't show a phantom last number.
2627	if n := len(lines); n > 0 && lines[n-1] == "" {
2628		lines = lines[:n-1]
2629	}
2630	// Split code from trailing footer lines ("... [truncated ...]")
2631	// so we don't number the footer. A single blank separator line
2632	// immediately before the footer is also pulled out so the
2633	// gutter doesn't render a phantom number for it.
2634	codeEnd := len(lines)
2635	for i := len(lines) - 1; i >= 0; i-- {
2636		if strings.HasPrefix(lines[i], "...") {
2637			codeEnd = i
2638			continue
2639		}
2640		break
2641	}
2642	if codeEnd > 0 && codeEnd < len(lines) && lines[codeEnd-1] == "" {
2643		codeEnd--
2644	}
2645	code := lines[:codeEnd]
2646	footer := lines[codeEnd:]
2647
2648	// Expand tabs to 4 spaces so Go / Makefile code renders
2649	// at a consistent width.
2650	for i, c := range code {
2651		code[i] = expandTabs(c)
2652	}
2653
2654	lang := LanguageFromPath(sourcePath)
2655	var highlighted []string
2656	if lang != "" {
2657		highlighted = v.Theme.HighlightCode(strings.Join(code, "\n"), lang)
2658		for len(highlighted) < len(code) {
2659			highlighted = append(highlighted, "")
2660		}
2661		if len(highlighted) > len(code) {
2662			highlighted = highlighted[:len(code)]
2663		}
2664	} else {
2665		highlighted = make([]string, len(code))
2666		for i, c := range code {
2667			highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2668		}
2669	}
2670
2671	out := make([]string, 0, len(lines))
2672	for i, c := range highlighted {
2673		gutter := fmt.Sprintf("%4d ", startLine+i)
2674		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, gutter)+c)
2675	}
2676	for _, f := range footer {
2677		out = append(out, "    "+v.Theme.FGColor(v.Theme.Muted, f))
2678	}
2679	return out
2680}
2681
2682// defaultToolArgWidth is the number of cells the tool-call header
2683// truncates the primary argument (path/command/query) to when the
2684// ZUT_TOOL_ARG_WIDTH environment variable is unset or invalid.
2685const defaultToolArgWidth = 60
2686
2687// toolArgWidth returns the maximum cell width for the truncated
2688// primary argument shown in a tool-call header. It defaults to
2689// defaultToolArgWidth but can be raised or lowered with the
2690// ZUT_TOOL_ARG_WIDTH environment variable, which is useful for wide
2691// terminals where the default clips long queries too aggressively.
2692// Values outside the sane range [20, 500] are ignored so a stray or
2693// malformed setting can never produce an unreadable header or an
2694// out-of-range slice.
2695func toolArgWidth() int {
2696	v := strings.TrimSpace(os.Getenv("ZUT_TOOL_ARG_WIDTH"))
2697	if v == "" {
2698		return defaultToolArgWidth
2699	}
2700	n, err := strconv.Atoi(v)
2701	if err != nil || n < 20 || n > 500 {
2702		return defaultToolArgWidth
2703	}
2704	return n
2705}
2706
2707// ShortArgs renders a tool call's arguments into a one-line
2708// suffix for the "tool name <args>" header. tool is the tool
2709// name so we can add shape-specific decorations: for read we
2710// append the requested line range (e.g. "path:1-200") pulled
2711// from the offset/limit args, which is useful context at a
2712// glance without expanding the result body. Other tools keep
2713// the legacy "path or command, truncated" shape.
2714//
2715// The truncation width defaults to 60 cells but can be tuned via
2716// the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).
2717//
2718// Exported because the interactive mode pre-populates the
2719// ToolCallView.Args field with this value as soon as the tool
2720// call is announced, so the live overlay's header matches what
2721// the finalised transcript will later render.
2722func ShortArgs(tool string, raw json.RawMessage) string {
2723	width := toolArgWidth()
2724	var v any
2725	if err := json.Unmarshal(raw, &v); err != nil {
2726		return ""
2727	}
2728	x, ok := v.(map[string]any)
2729	if !ok {
2730		b, _ := json.Marshal(v)
2731		s := oneLineToolLabel(string(b))
2732		if len(s) > width {
2733			s = s[:width-3] + "..."
2734		}
2735		return s
2736	}
2737	var primary string
2738	for _, k := range []string{"path", "file_path", "command"} {
2739		if s, ok := x[k].(string); ok {
2740			primary = s
2741			break
2742		}
2743	}
2744	if primary == "" {
2745		b, _ := json.Marshal(v)
2746		s := oneLineToolLabel(string(b))
2747		if len(s) > width {
2748			s = s[:width-3] + "..."
2749		}
2750		return s
2751	}
2752	primary = oneLineToolLabel(primary)
2753
2754	// Tool-specific decoration. Only the read tool gets a range
2755	// suffix for now; other tools just truncate the primary arg.
2756	suffix := ""
2757	switch strings.ToLower(tool) {
2758	case "read":
2759		start := 1
2760		if n, ok := toInt(x["offset"]); ok && n >= 1 {
2761			start = n
2762		}
2763		if lim, ok := toInt(x["limit"]); ok && lim > 0 {
2764			end := start + lim - 1
2765			suffix = fmt.Sprintf(":%d-%d", start, end)
2766		} else if start > 1 {
2767			suffix = fmt.Sprintf(":%d-", start)
2768		}
2769	}
2770
2771	// Truncate the primary arg leaving room for the suffix so the
2772	// range stays visible even on absurdly long paths.
2773	max := width - len(suffix)
2774	if max < 10 {
2775		max = 10
2776	}
2777	if len(primary) > max {
2778		primary = primary[:max-3] + "..."
2779	}
2780	return primary + suffix
2781}
2782
2783// toInt coerces a json.Unmarshal'd number (float64) or a string
2784// containing a number into an int. Returns ok=false if the value
2785// is neither. Used by shortArgs to survive model quirks where
2786// numeric args come back as strings.
2787func toInt(v any) (int, bool) {
2788	switch n := v.(type) {
2789	case float64:
2790		return int(n), true
2791	case int:
2792		return n, true
2793	case string:
2794		i, err := strconv.Atoi(strings.TrimSpace(n))
2795		if err != nil {
2796			return 0, false
2797		}
2798		return i, true
2799	}
2800	return 0, false
2801}
2802
2803// renderCompactionBlock renders a compaction summary as a distinct
2804// visual block in the chat. When collapsed it shows a one-line label
2805// with the pre-compaction token count; when expanded (ctrl+o) it
2806// shows the full summary text.
2807func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2808	th := v.Theme
2809	const indent = "    "
2810
2811	tokens := m.Meta["tokens_before"]
2812	if tokens == "" {
2813		tokens = "?"
2814	}
2815
2816	if v.ExpandAll {
2817		var lines []string
2818		header := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
2819		lines = append(lines, indent+header)
2820		lines = append(lines, "")
2821		for _, c := range m.Content {
2822			if tb, ok := c.(provider.TextBlock); ok {
2823				text := tb.Text
2824				if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
2825					text = text[idx+2:]
2826				}
2827				md := RenderMarkdown(text, th, width-4)
2828				for _, l := range strings.Split(md, "\n") {
2829					if len(l) > 0 && l[0] == FlushLeftSentinel {
2830						l = l[1:]
2831					}
2832					lines = append(lines, indent+l)
2833				}
2834			}
2835		}
2836		return lines
2837	}
2838
2839	// Collapsed: single line, no banner.
2840	line := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
2841	return []string{indent + line}
2842}
2843
2844// StatusBarParams groups the many bits of state the status bar needs.
2845// Grew from a flat argument list once we settled on the layout.
2846type StatusBarParams struct {
2847	Theme      Theme
2848	Provider   string
2849	Model      string
2850	Reasoning  string // "" means thinking off
2851	FastMode   bool   // show the provider's opt-in fast tier when enabled
2852	Busy       bool
2853	BusyPrefix string // spinner + funny line when busy
2854	CWD        string
2855	Locked     bool   // sandbox on?
2856	NoYolo     bool   // confirmation mode enabled?
2857	GoalStatus string // autonomous goal lifecycle, empty when none
2858
2859	// Cumulative session usage and cost.
2860	Usage provider.Usage
2861	// Subscription is true when the credential is an OAuth token (claude
2862	// pro/max, chatgpt plus/pro) rather than a paid api key. We still
2863	// compute a cost for visibility and append "(sub)" so the user
2864	// knows no real money moved.
2865	Subscription bool
2866
2867	// Last turn's input+cache tokens (approximates current live context).
2868	ContextUsed int
2869	ContextMax  int // model's context window; 0 disables the percentage
2870
2871	// AutoCompacting is true when the agent is currently running a
2872	// model-triggered condense pass. Surfaces as "(auto)" after the
2873	// context percentage so it's clear where the spinner is coming from.
2874	AutoCompacting bool
2875
2876	// Telegram true when the telegram bridge is connected. Adds a
2877	// small "- tg -" tag to the cwd line so the user can tell at a
2878	// glance that dms are being mirrored into this session.
2879	Telegram bool
2880
2881	Cols int // terminal width; drives right-alignment of cwd
2882}
2883
2884// StatusBar builds the status shown above the editor. Always returns
2885// two lines when a cwd is provided: the stats on the first line, the
2886// cwd on its own line below, indented to match the stats column. This
2887// keeps the status bar stable across terminal resizes (the cwd never
2888// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
2889// makes a long cwd safe at any width.
2890//
2891// Layout:
2892//
2893//	<busyPrefix>  (provider) model  stats   <- line 1
2894//	  cwd                                   <- line 2 (2-space indent)
2895//
2896// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
2897// The slash-command popup and the queued/sliding-in chips already
2898// cover the discoverability of those keybindings.
2899func StatusBar(p StatusBarParams) []string {
2900	th := p.Theme
2901
2902	stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription})
2903	if p.GoalStatus != "" {
2904		stats = append(stats, "goal:"+p.GoalStatus)
2905	}
2906
2907	// Context %. Color-coded: yellow >70, red >90.
2908	ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
2909	if ctx != "" {
2910		if p.AutoCompacting {
2911			ctx += " (auto)"
2912		}
2913		stats = append(stats, th.FGColor(ctxColor, ctx))
2914	}
2915
2916	// Layout uses exactly 2 spaces of horizontal padding everywhere:
2917	//   2 spaces  (openai) gpt-5.4  $0.000 (sub) 0.0%/400k  ~/Sites/zut
2918	// matches the editor prompt's left inset so the bar lines up
2919	// vertically with the conversation column.
2920	const pad = "  " // 2 spaces
2921
2922	left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2923	reasoning := reasoningLevelLabel(p.Reasoning)
2924	reasoningText := ""
2925	if reasoning != "" {
2926		reasoningText = "reasoning: " + reasoning
2927	}
2928	fastText := ""
2929	if p.FastMode {
2930		fastText = "fast mode"
2931	}
2932	statsText := strings.Join(stats, " ")
2933	middleParts := make([]string, 0, 3)
2934	if fastText != "" {
2935		middleParts = append(middleParts, fastText)
2936	}
2937	if reasoningText != "" {
2938		middleParts = append(middleParts, reasoningText)
2939	}
2940	if statsText != "" {
2941		middleParts = append(middleParts, statsText)
2942	}
2943	middle := strings.Join(middleParts, "  ")
2944
2945	var leftBuilder strings.Builder
2946	if p.BusyPrefix != "" {
2947		// Don't re-color the payload itself — the caller already set
2948		// fg colors on the spinner glyph, message, and elapsed
2949		// segments. Wrapping the whole thing here would override
2950		// those choices. The pad itself needs no color (it's spaces).
2951		leftBuilder.WriteString(pad + p.BusyPrefix)
2952		// Exactly one pad (2 spaces) between the busy segment and
2953		// the provider/model block. The leading pad above covers
2954		// the left indent.
2955		leftBuilder.WriteString(pad)
2956	} else {
2957		// Idle path: a single pad of left inset so the line
2958		// aligns with the conversation column on its left edge
2959		// ("  you" / "  zut" message markers). Without the busy
2960		// prefix there's no trailing separator to double-pad.
2961		leftBuilder.WriteString(pad)
2962	}
2963	leftBuilder.WriteString(th.FGColor(th.Muted, left))
2964	if middle != "" {
2965		leftBuilder.WriteString(pad)
2966		// Highlight the opt-in max tier; other status information stays muted.
2967		leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
2968	}
2969
2970	cwd := shortenHome(p.CWD)
2971	tags := ""
2972	if p.NoYolo {
2973		tags += "yolo mode disabled "
2974	}
2975	if p.Locked {
2976		tags += "jailed "
2977	}
2978	if p.Telegram {
2979		tags += "telegram connected "
2980	}
2981	if tags != "" && cwd != "" {
2982		cwd = tags + "- " + cwd
2983	}
2984
2985	primary := leftBuilder.String()
2986
2987	// On narrow terminals the single line wraps badly. If the visible
2988	// width exceeds cols and we have a busy prefix, split: keep the
2989	// busy prefix on line 1, then put model and (if still needed)
2990	// stats on their own rows. This mirrors the idle split below.
2991	if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
2992		busyLine := pad + p.BusyPrefix
2993		modelLine := pad + th.FGColor(th.Muted, left)
2994		lines := []string{busyLine}
2995		if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
2996			lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
2997		} else {
2998			var infoBuilder strings.Builder
2999			infoBuilder.WriteString(modelLine)
3000			if middle != "" {
3001				infoBuilder.WriteString(pad)
3002				infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
3003			}
3004			lines = append(lines, infoBuilder.String())
3005		}
3006		if cwd != "" {
3007			lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3008		}
3009		return lines
3010	}
3011
3012	// Idle narrow split: keep provider/model on the first status line,
3013	// move usage/cost/context stats to the next, then cwd below. This
3014	// avoids the terminal's hard wrap cutting the stats or pushing cwd
3015	// into an awkward position on small widths.
3016	if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
3017		var lines []string
3018		lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
3019		if cwd != "" {
3020			lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3021		}
3022		return lines
3023	}
3024
3025	if cwd == "" {
3026		return []string{primary}
3027	}
3028
3029	// Second line: indent with the same 2-space pad so the cwd lines
3030	// up under the "(provider)" column on line 1.
3031	cwdRendered := pad + th.FGColor(th.Muted, cwd)
3032	return []string{primary, cwdRendered}
3033}
3034
3035func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string {
3036	modelLine := pad + th.FGColor(th.Muted, modelText)
3037	infoParts := make([]string, 0, 2)
3038	if fastText != "" {
3039		infoParts = append(infoParts, fastText)
3040	}
3041	if reasoningText != "" {
3042		infoParts = append(infoParts, reasoningText)
3043	}
3044	infoText := strings.Join(infoParts, pad)
3045	if infoText == "" {
3046		lines = append(lines, modelLine)
3047		return appendWrappedUsageStats(lines, th, pad, stats, cols)
3048	}
3049
3050	modelInfoPlain := pad + modelText + pad + infoText
3051	infoColor := th.Muted
3052	if reasoningText != "" {
3053		infoColor = reasoningStatusColor(th, reasoningText)
3054	}
3055	if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
3056		lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
3057	} else {
3058		lines = append(lines, modelLine)
3059		if fastText != "" {
3060			lines = append(lines, pad+th.FGColor(th.Muted, fastText))
3061		}
3062		if reasoningText != "" {
3063			lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
3064		}
3065	}
3066	return appendWrappedUsageStats(lines, th, pad, stats, cols)
3067}
3068
3069func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string {
3070	if len(stats) == 0 {
3071		return lines
3072	}
3073	available := cols - visibleWidth(pad)
3074	if cols <= 0 || available <= 0 {
3075		return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " ")))
3076	}
3077	current := make([]string, 0, len(stats))
3078	flush := func() {
3079		if len(current) == 0 {
3080			return
3081		}
3082		line := pad + th.FGColor(th.Muted, strings.Join(current, " "))
3083		lines = append(lines, truncateToWidth(line, cols))
3084		current = current[:0]
3085	}
3086	for _, stat := range stats {
3087		candidate := append(append([]string(nil), current...), stat)
3088		if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available {
3089			flush()
3090		}
3091		current = append(current, stat)
3092	}
3093	flush()
3094	return lines
3095}
3096
3097func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3098	if strings.HasSuffix(reasoningText, ": max") {
3099		return th.ThinkingMax
3100	}
3101	return th.Muted
3102}
3103
3104func reasoningLevelLabel(level string) string {
3105	switch strings.ToLower(strings.TrimSpace(level)) {
3106	case "", "off", "none", "no", "false", "disabled":
3107		return ""
3108	case "minimum", "minimal", "min":
3109		return "minimal"
3110	case "maximum", "xhigh":
3111		return "xhigh"
3112	case "max":
3113		return "max"
3114	default:
3115		return strings.ToLower(strings.TrimSpace(level))
3116	}
3117}
3118
3119// contextUsage renders the "N%/ctxMax" fragment, returning the
3120// rendered string plus the colour to wrap it in.
3121func contextUsage(th Theme, used, max int) (string, TerminalColor) {
3122	if max <= 0 {
3123		if used <= 0 {
3124			return "", th.Muted
3125		}
3126		return formatTokens(used), th.Muted
3127	}
3128	pct := float64(used) / float64(max) * 100
3129	text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
3130	switch {
3131	case pct > 90:
3132		return text, th.Error
3133	case pct > 70:
3134		return text, th.Warning
3135	}
3136	return text, th.Muted
3137}
3138
3139// formatTokens footer formatter:
3140//
3141//	< 1000      -> "42"
3142//	< 10_000    -> "2.7k"
3143//	< 1_000_000 -> "35k"
3144//	< 10M       -> "1.1M"
3145//	else        -> "12M"
3146func formatTokens(n int) string {
3147	switch {
3148	case n < 0:
3149		return "0"
3150	case n < 1000:
3151		return fmt.Sprintf("%d", n)
3152	case n < 10000:
3153		return fmt.Sprintf("%.1fk", float64(n)/1000)
3154	case n < 1_000_000:
3155		return fmt.Sprintf("%dk", (n+500)/1000)
3156	case n < 10_000_000:
3157		return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
3158	default:
3159		return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
3160	}
3161}
3162
3163// shortenHome replaces the user's $HOME prefix with "~" for readability.
3164func shortenHome(p string) string {
3165	if p == "" {
3166		return ""
3167	}
3168	home := osUserHome()
3169	if home == "" {
3170		return p
3171	}
3172	if p == home {
3173		return "~"
3174	}
3175	if strings.HasPrefix(p, home+"/") {
3176		return "~" + p[len(home):]
3177	}
3178	return p
3179}
3180
3181// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
3182var osUserHome = func() string {
3183	if h, err := osUserHomeDir(); err == nil {
3184		return h
3185	}
3186	return ""
3187}

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