v0.43.0 63d78c8

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

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