main 4509ec7

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

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