v0.45.0 cb619f7

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

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