v0.27.0 01c3d31

view.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close