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