v0.38.1 969e52a

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

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