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