v0.44.0 efd47b3

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

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 477

↑↓ moveenter openesc close