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