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