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