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 // Each tool result owns a complete box: top edge with
1131 // the call's label, body rows wrapped in vertical
1132 // edges, bottom edge to close. The label is looked up
1133 // from the matching ToolCallBlock so multiple calls
1134 // in one assistant message render as N adjacent boxes
1135 // instead of stacking unclosed top edges.
1136 if v.FlatTools || v.CompactMode {
1137 if v.CompactMode {
1138 lines = append(lines, compactToolBlank(v.Theme, width))
1139 }
1140 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1141 if tr.IsError {
1142 lines = append(lines, toolBodyLine(v.Theme, v.Theme.FGColor(color, " error"), width, v.CompactMode))
1143 }
1144 forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1145 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
1146 _, stripped := parseImageFootprint(line)
1147 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1148 }
1149 if v.CompactMode {
1150 lines = append(lines, compactToolBlank(v.Theme, width))
1151 }
1152 continue
1153 }
1154 lines = append(lines, toolBoxTop(v.Theme, label, width))
1155 lines = append(lines, toolBoxSide(v.Theme, "", width))
1156 if tr.IsError {
1157 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FGColor(color, " error"), width))
1158 }
1159 forceDiff := astRewriteArgs(v.toolCallNames[tr.CallID], v.toolCallArgs[tr.CallID])
1160 for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine, forceDiff) {
1161 // Image-footprint rows (the escape row, the blank
1162 // reservation rows beneath it, and the gap row
1163 // before the metadata caption) are tagged with the
1164 // imageFootprintSentinel by renderImageBlock. Strip
1165 // the tag, parse the optional width hint, then wrap
1166 // the row in the usual │ ... │ box edges so the
1167 // frame stays continuous around the image.
1168 imgCells, stripped := parseImageFootprint(line)
1169 if hasImageEscapeLine(stripped) {
1170 lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1171 } else {
1172 lines = append(lines, toolBoxSide(v.Theme, stripped, width))
1173 }
1174 }
1175 lines = append(lines, toolBoxSide(v.Theme, "", width))
1176 lines = append(lines, toolBoxBottom(v.Theme, width))
1177 }
1178 }
1179 }
1180 return lines
1181}
1182
1183// RenderToolCall renders one live or completed tool call using the same
1184// presentation as tool calls in the main transcript. Dialogs with isolated
1185// agent loops use this to avoid maintaining a second tool renderer.
1186func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
1187 if v.liveBodyHigh == nil {
1188 v.liveBodyHigh = make(map[string]int)
1189 }
1190 return v.renderToolCallCached(tc, width, toolThemeKey(v.Theme))
1191}
1192
1193func (v *View) renderToolCallCached(tc ToolCallView, width int, themeKey uint64) []string {
1194 if v.liveRenderCache == nil {
1195 v.liveRenderCache = make(map[liveToolCacheKey][]string)
1196 }
1197 key := liveToolCacheKey{
1198 callID: tc.ID,
1199 revision: tc.Revision,
1200 content: hashLiveTool(tc),
1201 width: width,
1202 expandAll: v.ExpandAll,
1203 flatTools: v.FlatTools,
1204 compact: v.CompactMode,
1205 imageProto: v.ImageProto,
1206 theme: themeKey,
1207 }
1208 if lines, ok := v.liveRenderCache[key]; ok {
1209 return lines
1210 }
1211 lines := v.renderToolCall(tc, width)
1212 if len(v.liveRenderCache) > 512 {
1213 for k := range v.liveRenderCache {
1214 delete(v.liveRenderCache, k)
1215 break
1216 }
1217 }
1218 v.liveRenderCache[key] = lines
1219 return lines
1220}
1221
1222func hashLiveTool(tc ToolCallView) uint64 {
1223 if tc.Revision != 0 {
1224 return 0
1225 }
1226 h := fnv64aInit
1227 for _, s := range []string{tc.ID, tc.Name, tc.Args, tc.Preview, tc.Result, tc.RawJSONBuf, tc.LivePath} {
1228 h = fnv64aWrite(h, []byte(s))
1229 h = fnv64aWriteByte(h, 0)
1230 }
1231 if tc.Error {
1232 h = fnv64aWriteByte(h, 'e')
1233 }
1234 if tc.Done {
1235 h = fnv64aWriteByte(h, 'd')
1236 }
1237 if tc.Streaming {
1238 h = fnv64aWriteByte(h, 's')
1239 }
1240 return h
1241}
1242
1243func toolThemeKey(theme Theme) uint64 {
1244 h := fnv64aInit
1245 for _, s := range []string{
1246 theme.SyntaxBaseStyle,
1247 theme.Syntax.Keyword,
1248 theme.Syntax.KeywordConstant,
1249 theme.Syntax.KeywordDeclaration,
1250 theme.Syntax.KeywordNamespace,
1251 theme.Syntax.KeywordReserved,
1252 theme.Syntax.KeywordType,
1253 theme.Syntax.NameBuiltin,
1254 theme.Syntax.NameFunction,
1255 theme.Syntax.NameClass,
1256 theme.Syntax.NameDecorator,
1257 theme.Syntax.LiteralString,
1258 theme.Syntax.LiteralStringEscape,
1259 theme.Syntax.LiteralNumber,
1260 theme.Syntax.Comment,
1261 theme.Syntax.CommentPreproc,
1262 theme.Syntax.Operator,
1263 theme.Syntax.Punctuation,
1264 theme.Syntax.Text,
1265 } {
1266 h = fnv64aWrite(h, []byte(s))
1267 h = fnv64aWriteByte(h, 0)
1268 }
1269 for _, c := range []TerminalColor{
1270 theme.FG, theme.Muted, theme.Accent, theme.User, theme.UserBubbleBG,
1271 theme.UserBubbleFG, theme.Assistant, theme.Tool, theme.ToolOut,
1272 theme.Error, theme.Warning, theme.Spinner, theme.ThinkingMax,
1273 theme.SelectionBG, theme.SelectionFG, theme.Terminal.Foreground,
1274 theme.Terminal.Background,
1275 } {
1276 h = hashThemeColor(h, c)
1277 }
1278 if theme.Background == nil {
1279 h = fnv64aWriteByte(h, 0)
1280 } else {
1281 h = fnv64aWriteByte(h, 1)
1282 h = hashThemeColor(h, *theme.Background)
1283 }
1284 h = fnv64aWriteByte(h, 0xff)
1285 h = fnv64aWriteUint64(h, uint64(len(theme.Terminal.Palette)))
1286 h = fnv64aWriteByte(h, 0xff)
1287 for _, c := range theme.Terminal.Palette {
1288 h = hashThemeColor(h, c)
1289 }
1290 h = fnv64aWriteByte(h, byte(theme.Terminal.ColorDepth()))
1291 h = fnv64aWriteUint64(h, uint64(theme.Terminal.PaletteKnown))
1292 for _, value := range []bool{
1293 theme.UseTerminalPalette,
1294 theme.Terminal.HasForeground,
1295 theme.Terminal.HasBackground,
1296 theme.Terminal.TrueColor,
1297 theme.Terminal.Light,
1298 theme.Terminal.SchemeKnown,
1299 } {
1300 if value {
1301 h = fnv64aWriteByte(h, 1)
1302 } else {
1303 h = fnv64aWriteByte(h, 0)
1304 }
1305 }
1306 return h
1307}
1308
1309func hashThemeColor(h uint64, c TerminalColor) uint64 {
1310 h = fnv64aWriteByte(h, byte(c.Mode))
1311 h = fnv64aWriteByte(h, byte(c.Index))
1312 h = fnv64aWriteByte(h, byte(c.R))
1313 h = fnv64aWriteByte(h, byte(c.G))
1314 h = fnv64aWriteByte(h, byte(c.B))
1315 return h
1316}
1317
1318func (v *View) renderToolCall(tc ToolCallView, width int) []string {
1319 if isPlanToolName(tc.Name) {
1320 // The checklist is materialized only once core accepts the call, so the
1321 // live overlay renders from the snapshot map. Before execution there is
1322 // no snapshot yet: name the action instead of inventing a list.
1323 if update, ok := v.PlanUpdates[tc.ID]; ok {
1324 return v.renderPlanUpdate(update, width)
1325 }
1326 if !tc.Done && !tc.Error {
1327 return v.renderPlanCompact(tc, width)
1328 }
1329 }
1330
1331 var lines []string
1332
1333 // Header label. While the call is still streaming, prefer the
1334 // live path extracted from the partial args so the user sees
1335 // the target file as soon as it's known, even before the full
1336 // JSON arrived.
1337 arg := tc.Args
1338 if arg == "" && tc.LivePath != "" {
1339 arg = tc.LivePath
1340 }
1341 label := tc.Name + " " + arg
1342
1343 // No leading blank: Build()'s inter-message separator already
1344 // places one blank row between the previous transcript content
1345 // and the live overlay, matching the spacing the same call has
1346 // once it finalises into a transcript box. Adding another blank
1347 // here would double the gap during streaming and visibly tighten
1348 // when the overlay disappears.
1349
1350 // Live body (write/edit): keep the streamed preview visible until
1351 // a proposed or real tool result arrives. The provider can finish the
1352 // tool_use JSON before zut has executed the tool, so keying this on
1353 // tc.Streaming makes write/edit boxes collapse for a moment between
1354 // EvToolUseEnd and confirmation or EvToolResult.
1355 if tc.Preview == "" && tc.Result == "" {
1356 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
1357 // Pad the live preview up to the call's high-water height so
1358 // it never shrinks mid-stream (e.g. between edit 1 and the
1359 // start of edit 2). Padding sits inside the box as real
1360 // box-side rows so the surrounding layout stays put. The
1361 // high-water mark is per call id and is dropped the moment a
1362 // Result lands (this branch only runs while Result == "").
1363 high := len(body)
1364 // Live edits otherwise grow from a few rows to the collapsed
1365 // preview limit as newText arrives, moving the editor on every
1366 // line. Reserve the final collapsed height from the first visible
1367 // edit frame; ctrl+o still opts into unconstrained growth.
1368 if !v.ExpandAll && (tc.Name == "edit" || tc.Name == "Edit") && high < ToolCollapsePreview+2 {
1369 high = ToolCollapsePreview + 2
1370 }
1371 if v.liveBodyHigh != nil {
1372 if prev := v.liveBodyHigh[tc.ID]; prev > high {
1373 high = prev
1374 }
1375 v.liveBodyHigh[tc.ID] = high
1376 }
1377 if v.FlatTools || v.CompactMode {
1378 for len(body) < high {
1379 body = append(body, "")
1380 }
1381 if v.CompactMode {
1382 lines = append(lines, compactToolBlank(v.Theme, width))
1383 }
1384 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1385 lines = append(lines, body...)
1386 if v.CompactMode {
1387 lines = append(lines, compactToolBlank(v.Theme, width))
1388 }
1389 return lines
1390 }
1391 for len(body) < high {
1392 body = append(body, toolBoxSide(v.Theme, "", width))
1393 }
1394 lines = append(lines, toolBoxTop(v.Theme, label, width))
1395 lines = append(lines, toolBoxSide(v.Theme, "", width))
1396 lines = append(lines, body...)
1397 lines = append(lines, toolBoxSide(v.Theme, "", width))
1398 lines = append(lines, toolBoxBottom(v.Theme, width))
1399 return lines
1400 }
1401 }
1402
1403 bodyText := tc.Result
1404 if bodyText == "" {
1405 bodyText = tc.Preview
1406 }
1407
1408 // Finished tool call with no body: just the labelled top edge
1409 // directly closed by the bottom. Avoids a blank interior row
1410 // for no-output tools.
1411 if bodyText == "" {
1412 if v.FlatTools || v.CompactMode {
1413 if v.CompactMode {
1414 lines = append(lines, compactToolBlank(v.Theme, width))
1415 }
1416 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1417 if v.CompactMode {
1418 lines = append(lines, compactToolBlank(v.Theme, width))
1419 }
1420 return lines
1421 }
1422 lines = append(lines, toolBoxTop(v.Theme, label, width))
1423 lines = append(lines, toolBoxBottom(v.Theme, width))
1424 return lines
1425 }
1426
1427 // Finished tool call with a result body. Top edge embeds the
1428 // label; body lines go through toolBoxSide so each row gets
1429 // vertical edges; bottom edge closes the box. Blank interior
1430 // rows after the top and before the bottom give the body a bit
1431 // of breathing room from the corners.
1432 color := v.Theme.ToolOut
1433 if tc.Error {
1434 color = v.Theme.Error
1435 }
1436 if v.FlatTools || v.CompactMode {
1437 if v.CompactMode {
1438 lines = append(lines, compactToolBlank(v.Theme, width))
1439 }
1440 lines = append(lines, toolHeaderLine(v.Theme, label, width, v.CompactMode))
1441 body := v.renderLiveToolResult(bodyText, flatToolBodyRenderWidth(width), color, tc.LivePath)
1442 for _, l := range v.collapseToolBody(body, false) {
1443 _, stripped := parseImageFootprint(l)
1444 lines = append(lines, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1445 }
1446 if v.CompactMode {
1447 lines = append(lines, compactToolBlank(v.Theme, width))
1448 }
1449 return lines
1450 }
1451 lines = append(lines, toolBoxTop(v.Theme, label, width))
1452 lines = append(lines, toolBoxSide(v.Theme, "", width))
1453 body := v.renderLiveToolResult(bodyText, toolBoxBodyRenderWidth(width), color, tc.LivePath)
1454 for _, l := range v.collapseToolBody(body, false) {
1455 imgCells, stripped := parseImageFootprint(l)
1456 if hasImageEscapeLine(stripped) {
1457 lines = append(lines, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1458 continue
1459 }
1460 lines = append(lines, toolBoxSide(v.Theme, stripped, width))
1461 }
1462 lines = append(lines, toolBoxSide(v.Theme, "", width))
1463 lines = append(lines, toolBoxBottom(v.Theme, width))
1464 return lines
1465}
1466
1467// renderLiveToolResult uses the transcript's text renderer so every rich
1468// confirmation preview immediately gets the same colors, numbered gutters,
1469// and syntax highlighting as its final tool result.
1470func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string {
1471 full := v.renderToolText(text, width, color, sourcePath, 1)
1472 if v.ExpandAll || len(full) <= ToolCollapseLines {
1473 return full
1474 }
1475 return v.collapseToolBodyWithTotal(full, false, len(full))
1476}
1477
1478// renderLiveToolBody renders the in-flight preview of a streaming
1479// tool call. Supported tools:
1480//
1481// - write: shows the partial `content` field, syntax-highlighted
1482// by the target path's language.
1483// - edit: shows the partial `newText` of the edit currently being
1484// streamed, prefixed with a "editing foo.ts (edit 2)" header so
1485// the user can see which of a multi-edit batch is in progress.
1486// - bash: shows the full command as soon as the JSON args are known,
1487// before stdout/stderr has arrived, so long-running commands are legible.
1488//
1489// Anything else returns nil and only the tool-call header shows.
1490func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
1491 switch tc.Name {
1492 case "write", "Write":
1493 partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
1494 if !ok || partial == "" {
1495 return nil
1496 }
1497 return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
1498 case "edit", "Edit":
1499 partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
1500 if !ok || partial == "" {
1501 return nil
1502 }
1503 // Header line hints which edit is streaming and, when more
1504 // than one has landed, how many the model is doing.
1505 hint := fmt.Sprintf("edit %d (streaming)", idx)
1506 body := []string{" " + v.Theme.FGColor(v.Theme.Muted, hint), ""}
1507 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
1508 return v.wrapLiveBody(body, width)
1509 case "bash", "Bash":
1510 command, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "command")
1511 if !ok || strings.TrimSpace(command) == "" {
1512 return nil
1513 }
1514 return v.wrapLiveBody(v.renderLiveBashCommand(command, width), width)
1515 }
1516 return nil
1517}
1518
1519func (v *View) renderLiveBashCommand(command string, width int) []string {
1520 inner := toolBoxBodyRenderWidth(width)
1521 if v.FlatTools || v.CompactMode {
1522 inner = flatToolBodyRenderWidth(width)
1523 }
1524 prompt := v.Theme.FGColor(v.Theme.Muted, "$ ")
1525 var out []string
1526 for i, line := range splitLines(sanitizeText(command, terminalTabStop)) {
1527 firstPrefix := " "
1528 if i == 0 {
1529 firstPrefix += prompt
1530 } else {
1531 firstPrefix += " "
1532 }
1533 contPrefix := " "
1534 wrapWidth := inner - visibleWidth(firstPrefix)
1535 if wrapWidth < 10 {
1536 wrapWidth = 10
1537 }
1538 for j, wrapped := range wrapANSILine(v.Theme.FGColor(v.Theme.ToolOut, line), wrapWidth) {
1539 prefix := firstPrefix
1540 if j > 0 {
1541 prefix = contPrefix
1542 }
1543 out = append(out, prefix+wrapped)
1544 }
1545 }
1546 return out
1547}
1548
1549// wrapLiveBody returns the streaming body content as a list of
1550// box-side rows: each line wrapped in │ ... │ with right padding so
1551// the closing edge sits at column width-1. The caller (renderToolCall)
1552// supplies the surrounding top/bottom edges so the live overlay
1553// renders as a closed box matching the finalised transcript form.
1554func (v *View) wrapLiveBody(body []string, width int) []string {
1555 body = v.collapseToolBody(body, false)
1556 out := make([]string, 0, len(body))
1557 if v.FlatTools || v.CompactMode {
1558 for _, l := range body {
1559 _, stripped := parseImageFootprint(l)
1560 out = append(out, toolBodyLine(v.Theme, stripped, width, v.CompactMode))
1561 }
1562 return out
1563 }
1564 for _, l := range body {
1565 imgCells, stripped := parseImageFootprint(l)
1566 if hasImageEscapeLine(stripped) {
1567 out = append(out, toolBoxSideWithImage(v.Theme, stripped, imgCells, width))
1568 continue
1569 }
1570 out = append(out, toolBoxSide(v.Theme, stripped, width))
1571 }
1572 return out
1573}
1574
1575// toolBoxInnerPad is the number of blank cells kept between a box
1576// edge (┌, │, └) and the content inside it. One cell of breathing
1577// room makes the label read as part of the box rather than fused to
1578// the corner, and gives body lines a tiny gutter from the left side.
1579const toolBoxInnerPad = 1
1580
1581// toolBoxOuterMargin is the number of blank cells kept to the left of
1582// the opening corner and to the right of the closing corner. Aligns
1583// the box's frame with the column where user-bubble text begins, so
1584// the conversation reads as one column instead of having tool boxes
1585// running edge-to-edge while user/assistant rows sit indented.
1586const toolBoxOuterMargin = 2
1587
1588// toolBoxTop renders the labelled top edge of a tool block:
1589//
1590// ┌─ bash xcrun simctl list devices ─────────────────────────────┐
1591//
1592// The label is the tool name + short args (the same string previously
1593// shown on its own row beneath the rule). Padding fills the remainder
1594// of the line so the right corner sits at column width-1, matching the
1595// closing edge.
1596func toolBoxTop(th Theme, label string, width int) string {
1597 label = oneLineToolLabel(label)
1598 w := width - 2*toolBoxOuterMargin
1599 if w < 12 {
1600 w = 12
1601 }
1602 margin := strings.Repeat(" ", toolBoxOuterMargin)
1603 innerPad := strings.Repeat(" ", toolBoxInnerPad)
1604 // "┌─" + innerPad + " " + label + " " + innerPad = used; pad
1605 // with ─ to width-1, then "┐".
1606 prefix := "┌─" + innerPad + " "
1607 suffix := " " + innerPad
1608 used := visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1609 fill := w - used - 1 // -1 for the closing corner
1610 if fill < 0 {
1611 // Label overflows; truncate with an ellipsis so the right
1612 // corner still lands on the right edge. Rare on real
1613 // terminals (the chat column is usually wide enough).
1614 label = truncateWithEllipsis(label, visibleWidth(label)+fill)
1615 used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix)
1616 fill = w - used - 1
1617 if fill < 0 {
1618 fill = 0
1619 }
1620 }
1621 fillStr := strings.Repeat("─", fill)
1622 name, rest := splitToolLabel(label)
1623 return margin + th.FGColor(th.Muted, prefix) + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest+suffix+fillStr+"┐") + margin
1624}
1625
1626// truncateWithEllipsis shortens plain text to at most limit terminal
1627// cells, ending with "..." when it had to cut. Counting cells rather than
1628// runes keeps wide and zero-width characters from pushing the row past
1629// limit.
1630func truncateWithEllipsis(s string, limit int) string {
1631 if visibleWidth(s) <= limit {
1632 return s
1633 }
1634 if limit <= 3 {
1635 return strings.Repeat(".", max(0, limit))
1636 }
1637 return truncateToWidth(s, limit-3) + "..."
1638}
1639
1640func oneLineToolLabel(label string) string {
1641 return strings.Join(strings.Fields(sanitizeLine(label, 1)), " ")
1642}
1643
1644func splitToolLabel(label string) (name, rest string) {
1645 label = strings.TrimLeft(label, " ")
1646 if label == "" {
1647 return "", ""
1648 }
1649 idx := strings.IndexAny(label, " \t")
1650 if idx < 0 {
1651 return label, ""
1652 }
1653 return label[:idx], label[idx:]
1654}
1655
1656// flatToolHeader renders the boxless header line for a tool call:
1657//
1658// ▌ bash xcrun simctl list devices
1659//
1660// It carries the same information as toolBoxTop (tool name + short
1661// args) but drops the frame: a single accent gutter glyph, the tool
1662// name in the foreground color, and the argument summary muted. The
1663// line is left-aligned at the same column the box's opening corner
1664// used (toolBoxOuterMargin) so flat and box renders share a column.
1665func toolHeaderLine(th Theme, label string, width int, compact bool) string {
1666 if compact {
1667 return compactToolHeader(th, label, width)
1668 }
1669 return flatToolHeader(th, label, width)
1670}
1671
1672func flatToolHeader(th Theme, label string, width int) string {
1673 label = oneLineToolLabel(label)
1674 name, rest := splitToolLabel(label)
1675 margin := strings.Repeat(" ", toolBoxOuterMargin)
1676 gutter := th.FGColor(th.Assistant, "▌") + " "
1677 // Budget the visible text to the content width so a very long
1678 // argument summary doesn't run off the right edge; truncate with
1679 // an ellipsis like the box header does.
1680 avail := width - toolBoxOuterMargin - visibleWidth("▌ ")
1681 if avail < 12 {
1682 avail = 12
1683 }
1684 if visibleWidth(name+rest) > avail {
1685 if visibleWidth(name)+3 < avail {
1686 rest = truncateWithEllipsis(rest, avail-visibleWidth(name))
1687 } else if visibleWidth(name) <= avail {
1688 rest = ""
1689 }
1690 }
1691 return margin + gutter + th.FGColor(th.FG, name) + th.FGColor(th.Muted, rest)
1692}
1693
1694func compactToolBlank(th Theme, width int) string {
1695 if width < 1 {
1696 width = 1
1697 }
1698 return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1699}
1700
1701func compactToolHeader(th Theme, label string, width int) string {
1702 label = oneLineToolLabel(label)
1703 name, rest := splitToolLabel(label)
1704 pad := " "
1705 avail := width - visibleWidth(pad)*2
1706 if avail < 12 {
1707 avail = 12
1708 }
1709 if visibleWidth(name+rest) > avail {
1710 if visibleWidth(name)+3 < avail {
1711 rest = truncateWithEllipsis(rest, avail-visibleWidth(name))
1712 } else if visibleWidth(name) <= avail {
1713 rest = ""
1714 }
1715 }
1716 plain := pad + name + rest
1717 visible := visibleWidth(plain)
1718 fill := ""
1719 if visible < width {
1720 fill = strings.Repeat(" ", width-visible)
1721 }
1722 bg := th.sgrBGColor(th.UserBubbleBG)
1723 return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset
1724}
1725
1726func toolBodyLine(th Theme, line string, width int, compact bool) string {
1727 line = flatToolBody(th, line)
1728 if compact {
1729 return compactToolBody(th, line, width)
1730 }
1731 return line
1732}
1733
1734// flatToolBody indents a single body line for boxless rendering. It
1735// trims the same leading padding box bodies do, then prepends a fixed
1736// gutter indent so output sits in a readable column under the header
1737// without any vertical frame. ANSI styling in the line is preserved.
1738func flatToolBody(th Theme, line string) string {
1739 _ = th
1740 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1741 return strings.Repeat(" ", toolBoxOuterMargin+2) + line
1742}
1743
1744func compactToolBody(th Theme, line string, width int) string {
1745 visible := visibleWidth(line)
1746 fill := ""
1747 if visible < width {
1748 fill = strings.Repeat(" ", width-visible)
1749 }
1750 bg := th.sgrBGColor(th.UserBubbleBG)
1751 return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1752}
1753
1754// toolBoxBottom renders the bottom edge of a tool block:
1755//
1756// └─────────────────────────────────────────────────────────────────────────────┘
1757//
1758// Spans the same width as toolBoxTop so the corners line up.
1759func toolBoxBottom(th Theme, width int) string {
1760 w := width - 2*toolBoxOuterMargin
1761 if w < 12 {
1762 w = 12
1763 }
1764 margin := strings.Repeat(" ", toolBoxOuterMargin)
1765 line := "└" + strings.Repeat("─", w-2) + "┘"
1766 return margin + th.FGColor(th.Muted, line) + margin
1767}
1768
1769// hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
1770// iTerm2 (\x1b]1337;File=) inline-image escape. Such rows draw into
1771// the terminal's graphics layer and shouldn't be wrapped with text
1772// box edges.
1773func hasImageEscapeLine(s string) bool {
1774 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
1775}
1776
1777// imageFootprintSentinel marks rows that belong to an inline-image's
1778// reserved footprint — the escape row plus the blank rows below it
1779// plus the gap row before the metadata caption. Any consumer that
1780// wraps content in box edges (│ ... │) detects the sentinel, strips
1781// it, and emits the row — the image graphics rectangle paints over
1782// whatever was drawn there. Uses a non-printing C0 control byte so
1783// it can never appear in normal text or in an ANSI escape sequence
1784// body.
1785//
1786// The escape row carries an extra width hint between two sentinels:
1787// "\x1e<cells>\x1e<spaces+escape>". Wrappers parse the cells count
1788// to know how wide the image is in terminal cells and how many cells
1789// of trailing padding are needed before the closing box edge.
1790const imageFootprintSentinel = "\x1e"
1791
1792// parseImageFootprint splits a footprint-tagged row into (cellsWide,
1793// rest). cellsWide is 0 when the row carries no width hint (the
1794// reservation blanks and gap row don't, only the escape row does).
1795func parseImageFootprint(s string) (int, string) {
1796 if !strings.HasPrefix(s, imageFootprintSentinel) {
1797 return 0, s
1798 }
1799 rest := s[len(imageFootprintSentinel):]
1800 end := strings.Index(rest, imageFootprintSentinel)
1801 if end < 0 {
1802 return 0, rest
1803 }
1804 w, err := strconv.Atoi(rest[:end])
1805 if err != nil {
1806 return 0, rest
1807 }
1808 return w, rest[end+len(imageFootprintSentinel):]
1809}
1810
1811// toolBoxBodyTrimLeft is the number of leading literal spaces stripped
1812// from each body line as it enters toolBoxSide. Body renderers
1813// (renderToolText, renderRawFile, the diff/bash helpers, ...) all
1814// emit a 4-cell indent so the content aligns with the assistant body
1815// column when there's no box around it. Inside a box the box edge +
1816// inner pad already supply most of that gutter, so we drop a couple
1817// of cells from the body's own indent to keep the content from
1818// drifting too far right.
1819const toolBoxBodyTrimLeft = 2
1820
1821// toolBoxBodyRenderWidth is the width body renderers should target before
1822// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
1823// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
1824// then adds the box edge/padding. Passing the full terminal width lets long
1825// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
1826// visually breaking the right edge. This returns the maximum renderer width
1827// whose post-trim visible width fits inside the box.
1828func toolBoxBodyRenderWidth(width int) int {
1829 w := width - 2*toolBoxOuterMargin
1830 if w < 12 {
1831 w = 12
1832 }
1833 inner := w - 2 - 2*toolBoxInnerPad
1834 if inner < 1 {
1835 inner = 1
1836 }
1837 return inner + toolBoxBodyTrimLeft
1838}
1839
1840// flatToolBodyRenderWidth is the width body renderers should target in
1841// flat mode. There's no box frame, just the flatToolBody indent
1842// (toolBoxOuterMargin+2 cells), so the renderable width is the
1843// terminal width minus that indent. Renderers emit a 4-cell indent of
1844// their own which flatToolBody trims toolBoxBodyTrimLeft cells from,
1845// so add that back like toolBoxBodyRenderWidth does.
1846func flatToolBodyRenderWidth(width int) int {
1847 inner := width - (toolBoxOuterMargin + 2)
1848 if inner < 1 {
1849 inner = 1
1850 }
1851 return inner + toolBoxBodyTrimLeft
1852}
1853
1854// toolBoxSide wraps a single body line with vertical box edges:
1855//
1856// │ foo bar baz │
1857//
1858// The middle preserves the line verbatim (including any ANSI escapes)
1859// after stripping toolBoxBodyTrimLeft leading literal spaces. One cell
1860// of inner padding sits on each side so the content breathes from the
1861// edges; the right padding fills to column width-1.
1862func toolBoxSide(th Theme, line string, width int) string {
1863 w := width - 2*toolBoxOuterMargin
1864 if w < 12 {
1865 w = 12
1866 }
1867 margin := strings.Repeat(" ", toolBoxOuterMargin)
1868 mutedEdge := th.FGColor(th.Muted, "│")
1869 left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1870 right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1871 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
1872 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1873
1874 cur := visibleWidth(line)
1875 if cur > inner {
1876 // Last-resort truncation must retain syntax foreground escapes.
1877 // Stripping ANSI here made long read results switch from their
1878 // configured syntax color to the terminal's default foreground
1879 // whenever the window became narrow enough to clip a line.
1880 line = truncateToWidth(line, inner)
1881 cur = visibleWidth(line)
1882 }
1883 pad := inner - cur
1884 if pad < 0 {
1885 pad = 0
1886 }
1887 return margin + left + line + strings.Repeat(" ", pad) + right + margin
1888}
1889
1890// toolBoxSideWithImage wraps a row that carries an inline-image
1891// escape sequence. The image protocols are configured to not move
1892// the cursor after rendering (iTerm: doNotMoveCursor=1, kitty/
1893// ghostty: C=1), so the only visible cell advance on the row is
1894// from the leading indent spaces before the escape. We pad from
1895// there to the right edge column and draw the closing │ there.
1896// imgCells is unused (kept in the signature for compatibility with
1897// the parseImageFootprint width hint).
1898func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
1899 _ = imgCells
1900 w := width - 2*toolBoxOuterMargin
1901 if w < 12 {
1902 w = 12
1903 }
1904 margin := strings.Repeat(" ", toolBoxOuterMargin)
1905 mutedEdge := th.FGColor(th.Muted, "│")
1906 left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad)
1907 right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge
1908 inner := w - 2 - 2*toolBoxInnerPad
1909
1910 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
1911
1912 // Count leading spaces (visible cells consumed before the escape).
1913 leading := 0
1914 for leading < len(line) && line[leading] == ' ' {
1915 leading++
1916 }
1917
1918 pad := inner - leading
1919 if pad < 0 {
1920 pad = 0
1921 }
1922 return margin + left + line + strings.Repeat(" ", pad) + right + margin
1923}
1924
1925// trimLeadingSpaces removes up to n literal space characters from the
1926// start of s without touching anything else. ANSI CSI escapes that
1927// appear before the spaces are skipped over (so a coloured indent
1928// like "\x1b[2m text" still loses its first n cells of indent).
1929// Stops as soon as it hits a non-space character.
1930func trimLeadingSpaces(s string, n int) string {
1931 if n <= 0 || s == "" {
1932 return s
1933 }
1934 var b strings.Builder
1935 b.Grow(len(s))
1936 trimmed := 0
1937 i := 0
1938 for i < len(s) {
1939 // Pass through any ANSI escape sequence verbatim.
1940 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1941 end := i + 2
1942 for end < len(s) {
1943 c := s[end]
1944 end++
1945 if c >= 0x40 && c <= 0x7e {
1946 break
1947 }
1948 }
1949 b.WriteString(s[i:end])
1950 i = end
1951 continue
1952 }
1953 if trimmed < n && s[i] == ' ' {
1954 trimmed++
1955 i++
1956 continue
1957 }
1958 b.WriteString(s[i:])
1959 return b.String()
1960 }
1961 return b.String()
1962}
1963
1964// renderToolResultContent renders the body of a tool result block.
1965// Text blocks get the usual rules-wrapped treatment; text that looks
1966// like a unified diff gets +/- coloring. Image blocks are rendered
1967// inline when the terminal supports a protocol, else as a text
1968// placeholder with dimensions.
1969func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int, forceDiff bool) []string {
1970 var body []string
1971 hasImage := false
1972 for _, b := range blocks {
1973 if _, ok := b.(provider.ImageBlock); ok {
1974 hasImage = true
1975 break
1976 }
1977 }
1978 total := 0
1979 for _, b := range blocks {
1980 switch bb := b.(type) {
1981 case provider.TextBlock:
1982 bodyWidth := toolBoxBodyRenderWidth(width)
1983 if v.FlatTools || v.CompactMode {
1984 bodyWidth = flatToolBodyRenderWidth(width)
1985 }
1986 estimateWidth := effectiveToolTextWidth(bb.Text, bodyWidth)
1987 if forceDiff {
1988 estimateWidth = max(1, bodyWidth-5)
1989 }
1990 estimated := estimateToolTextLines(bb.Text, estimateWidth)
1991 total += estimated
1992 text := bb.Text
1993 // A collapsed result only exposes its first ten rendered rows.
1994 // Render a bounded source preview in that case; syntax
1995 // highlighting and wrapping the omitted tail is wasted work.
1996 if !v.ExpandAll && !hasImage && estimated > ToolCollapseLines {
1997 text = previewToolText(text, estimateWidth)
1998 }
1999 if forceDiff {
2000 body = append(body, v.renderUnifiedDiff(text, bodyWidth, sourcePath)...)
2001 } else {
2002 body = append(body, v.renderToolText(text, bodyWidth, color, sourcePath, startLine)...)
2003 }
2004 case provider.ImageBlock:
2005 body = append(body, v.renderImageBlock(bb, width)...)
2006 }
2007 }
2008 return v.collapseToolBodyWithTotal(body, hasImage, total)
2009}
2010
2011// collapseToolBody trims lines to the configured preview size when the
2012// view is not in ExpandAll mode, appending a muted "... ctrl+o to
2013// expand" footer. Image blocks never collapse — they're short in text
2014// rows but represent real content the user wants to see.
2015func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
2016 return v.collapseToolBodyWithTotal(lines, hasImage, len(lines))
2017}
2018
2019func (v *View) collapseToolBodyWithTotal(lines []string, hasImage bool, total int) []string {
2020 if v.ExpandAll || hasImage {
2021 return lines
2022 }
2023 if total < len(lines) {
2024 total = len(lines)
2025 }
2026 if total <= ToolCollapseLines {
2027 return lines
2028 }
2029 kept := lines
2030 if len(kept) > ToolCollapsePreview {
2031 kept = kept[:ToolCollapsePreview]
2032 }
2033 hidden := total - ToolCollapsePreview
2034 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
2035 footer = v.Theme.FGColor(v.Theme.Muted, footer)
2036 out := append([]string(nil), kept...)
2037 out = append(out, "")
2038 return append(out, footer)
2039}
2040
2041func effectiveToolTextWidth(text string, width int) int {
2042 // renderToolText prefixes ordinary rows with a four-cell gutter before
2043 // wrapping them. Diff rows also carry a five-cell read gutter. Estimating
2044 // against the outer body width undercounts long rows and produces an
2045 // incorrect collapse total.
2046 width -= 4
2047 if looksLikeUnifiedDiff(text) {
2048 width -= 5
2049 }
2050 if width < 1 {
2051 return 1
2052 }
2053 return width
2054}
2055
2056func estimateToolTextLines(text string, width int) int {
2057 if width < 1 {
2058 width = 1
2059 }
2060 total := 0
2061 for _, line := range strings.Split(text, "\n") {
2062 cells := runewidth.StringWidth(line)
2063 if cells == 0 {
2064 total++
2065 continue
2066 }
2067 total += (cells + width - 1) / width
2068 }
2069 if total == 0 {
2070 return 1
2071 }
2072 return total
2073}
2074
2075func previewToolText(text string, width int) string {
2076 if width < 1 {
2077 width = 1
2078 }
2079 maxChars := width * (ToolCollapsePreview + 2)
2080 if maxChars < 256 {
2081 maxChars = 256
2082 }
2083 if maxChars > 8192 {
2084 maxChars = 8192
2085 }
2086 end := 0
2087 for count := 0; end < len(text) && count < maxChars; count++ {
2088 _, size := utf8.DecodeRuneInString(text[end:])
2089 if size == 0 {
2090 break
2091 }
2092 end += size
2093 }
2094 preview := text[:end]
2095 lines := strings.SplitAfter(preview, "\n")
2096 if len(lines) > ToolCollapsePreview+2 {
2097 preview = strings.Join(lines[:ToolCollapsePreview+2], "")
2098 }
2099 return preview
2100}
2101
2102// renderToolText renders a text block inside a tool result. If the
2103// text contains a unified-diff section (lines starting with "--- " /
2104// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
2105// colors matching git diff conventions.
2106func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string {
2107 // Legacy path: transcripts saved before we dropped line numbers
2108 // from the read tool still carry " 1\t..." prefixes. Detect and
2109 // strip them, then fall through to the highlighter.
2110 if looksLikeNumberedFile(text) {
2111 return v.renderNumberedFile(text, sourcePath)
2112 }
2113 // If the result embeds a unified diff (the edit tool's output
2114 // starts with a short "applied N edit(s)" line and then a
2115 // standard --- / +++ / +/- patch), render the patch with
2116 // add/remove coloring. This takes priority over the file-like
2117 // detector below because a diff technically has many lines of
2118 // "file content" but what the user cares about is what changed,
2119 // not a dump of the post-edit file.
2120 if looksLikeUnifiedDiff(text) {
2121 return v.renderUnifiedDiff(text, width, sourcePath)
2122 }
2123
2124 // Current path: text came from `read` as raw file bytes. When a
2125 // source path is known (the call had a `path` arg), render with
2126 // a synthetic line-number gutter starting at startLine so the
2127 // on-screen view still looks like cat -n. Doesn't apply to non-
2128 // file tool outputs (bash stdout, display notes, etc.).
2129 if sourcePath != "" && looksLikeFileContent(text) {
2130 return v.renderRawFile(text, sourcePath, startLine)
2131 }
2132
2133 // No truncation — the full tool output is rendered into chat and
2134 // becomes part of the scrollback you can page back through.
2135 lines := splitLines(text)
2136
2137 // Bash-result styling: when the first row looks like a shell
2138 // prompt line ("$ ...") emitted by the bash tool, style the
2139 // prompt line in accent and the trailing "[exit N] Took X.Ys"
2140 // line in muted type so the command + timing read at a glance.
2141 // Everything between is left on the default tool-output color.
2142 if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
2143 return v.renderBashResult(lines, width, defaultColor)
2144 }
2145
2146 inDiff := false
2147 oldLine, newLine := 1, 1
2148 var out []string
2149 for _, l := range lines {
2150 // Detect diff header: "--- name" followed somewhere by "+++ name".
2151 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
2152 inDiff = true
2153 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop)))
2154 continue
2155 }
2156 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
2157 // that skip around in the file still get correct numbering.
2158 if inDiff && strings.HasPrefix(l, "@@") {
2159 if o, n, ok := parseHunkHeader(l); ok {
2160 oldLine, newLine = o, n
2161 }
2162 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, sanitizeLine(l, terminalTabStop)))
2163 continue
2164 }
2165 if inDiff && strings.TrimSpace(l) == "..." {
2166 out = append(out, "")
2167 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
2168 continue
2169 }
2170 if inDiff && len(l) > 0 {
2171 switch l[0] {
2172 case '+':
2173 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2174 newLine++
2175 continue
2176 case '-':
2177 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2178 oldLine++
2179 continue
2180 case ' ':
2181 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2182 oldLine++
2183 newLine++
2184 continue
2185 }
2186 }
2187 // Regular line.
2188 for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") {
2189 out = append(out, " "+v.Theme.FGColor(defaultColor, w))
2190 }
2191 }
2192 return out
2193}
2194
2195// parseHunkHeader extracts the starting old/new line from a unified
2196// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
2197// header is malformed or missing numbers.
2198func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
2199 // Skip "@@ "
2200 rest := strings.TrimPrefix(l, "@@")
2201 rest = strings.TrimSpace(rest)
2202 if !strings.HasPrefix(rest, "-") {
2203 return 0, 0, false
2204 }
2205 rest = rest[1:]
2206 space := strings.IndexByte(rest, ' ')
2207 if space < 0 {
2208 return 0, 0, false
2209 }
2210 oldPart := rest[:space]
2211 rest = strings.TrimSpace(rest[space+1:])
2212 if !strings.HasPrefix(rest, "+") {
2213 return 0, 0, false
2214 }
2215 rest = rest[1:]
2216 if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
2217 rest = rest[:sp]
2218 }
2219 parseStart := func(s string) (int, bool) {
2220 if c := strings.IndexByte(s, ','); c >= 0 {
2221 s = s[:c]
2222 }
2223 n, err := strconv.Atoi(s)
2224 if err != nil || n < 1 {
2225 return 0, false
2226 }
2227 return n, true
2228 }
2229 o, ok1 := parseStart(oldPart)
2230 n, ok2 := parseStart(rest)
2231 if !ok1 || !ok2 {
2232 return 0, 0, false
2233 }
2234 return o, n, true
2235}
2236
2237// renderDiffRow renders one unified-diff source line as one or more visual
2238// rows. The first row carries the read-style line-number gutter; continuation
2239// rows use a blank gutter so wrapped content cannot be mistaken for another
2240// source line. Code is syntax-highlighted if sourcePath hints at a known
2241// language and otherwise uses the diff color.
2242func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string {
2243 if len(line) == 0 {
2244 return []string{""}
2245 }
2246 code := sanitizeLine(line[1:], codeTabStop) // strip the leading marker
2247
2248 // Gutter shape: sign + number share a color so they read as one
2249 // visual token ("+123") instead of a neutral line number next to
2250 // a stray marker. Unchanged context lines get a muted gutter and
2251 // a leading space so column alignment stays consistent with +/-
2252 // rows.
2253 var gutterText string
2254 switch mark {
2255 case '+':
2256 gutterText = fmt.Sprintf("+%3d ", lineNo)
2257 case '-':
2258 gutterText = fmt.Sprintf("-%3d ", lineNo)
2259 default:
2260 gutterText = fmt.Sprintf(" %3d ", lineNo)
2261 }
2262 gutterColor := color
2263 if mark == ' ' {
2264 gutterColor = v.Theme.Muted
2265 }
2266
2267 maxCode := width - visibleWidth(" "+gutterText)
2268 if maxCode < 1 {
2269 maxCode = 1
2270 }
2271 chunks := wrapCodeLine(code, maxCode)
2272 lang := LanguageFromPath(sourcePath)
2273 out := make([]string, 0, len(chunks))
2274 for i, chunk := range chunks {
2275 rowGutter := gutterText
2276 if i > 0 {
2277 rowGutter = strings.Repeat(" ", visibleWidth(gutterText))
2278 }
2279 codeRendered := ""
2280 if lang != "" {
2281 if h := v.Theme.HighlightCode(chunk, lang); len(h) == 1 {
2282 codeRendered = h[0]
2283 }
2284 }
2285 if codeRendered == "" {
2286 codeColor := color
2287 if mark == ' ' {
2288 codeColor = v.Theme.Muted
2289 }
2290 codeRendered = v.Theme.FGColor(codeColor, chunk)
2291 }
2292 out = append(out, " "+v.Theme.FGColor(gutterColor, rowGutter)+codeRendered)
2293 }
2294 return out
2295}
2296
2297// wrapCodeLine splits code to a terminal cell width without dropping any
2298// characters. It prefers breaking after whitespace, then falls back to a hard
2299// rune boundary for long tokens. Keeping the whitespace on the preceding row
2300// means joining the returned chunks reconstructs the expanded source exactly.
2301func wrapCodeLine(line string, width int) []string {
2302 if width < 1 || visibleWidth(line) <= width {
2303 return []string{line}
2304 }
2305 runes := []rune(line)
2306 var out []string
2307 for start := 0; start < len(runes); {
2308 cells := 0
2309 end := start
2310 lastBreak := -1
2311 for end < len(runes) {
2312 runeWidth := runewidth.RuneWidth(runes[end])
2313 if cells+runeWidth > width && end > start {
2314 break
2315 }
2316 cells += runeWidth
2317 end++
2318 if runes[end-1] == ' ' || runes[end-1] == '\t' {
2319 lastBreak = end
2320 }
2321 if cells >= width {
2322 break
2323 }
2324 }
2325 if end == start { // A rune wider than the requested width.
2326 end++
2327 }
2328 if end < len(runes) && lastBreak > start {
2329 end = lastBreak
2330 }
2331 out = append(out, string(runes[start:end]))
2332 start = end
2333 }
2334 return out
2335}
2336
2337// renderImageBlock returns the lines for one image, inline if possible.
2338//
2339// Inline image escapes paint into multiple terminal rows but the zut
2340// renderer treats each slice entry as a single row. To prevent chat
2341// content from being drawn on top of the image, we pad with blank rows
2342// so the image's real footprint is reflected in the frame height.
2343func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
2344 w, h := ImageDimensions(b.Data)
2345 kb := len(b.Data) / 1024
2346 // Four-space indent matches every other tool-output row
2347 // (renderRawFile, renderBashResult, diff rows, "... N more
2348 // lines" footers). Keeps the image's metadata line vertically
2349 // aligned with the file content above / below it.
2350 info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb)
2351
2352 if v.ImageProto != ImageProtocolNone {
2353 // Clamp rendered width so the image never overflows the chat
2354 // column. Subtract a 4-cell indent for the tool result block,
2355 // cap at 60 cells, floor at 10.
2356 cells := width - 4
2357 if cells > 60 {
2358 cells = 60
2359 }
2360 if cells < 10 {
2361 cells = 10
2362 }
2363 const maxRows = 20
2364 if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
2365 rows, actualCells := InlineImageFootprint(b.Data, cells, maxRows)
2366 if rows < 1 {
2367 rows = 1
2368 }
2369 if actualCells < 1 {
2370 actualCells = cells
2371 }
2372 // Reserve the image footprint first, then place the escape
2373 // in the top-left of that blank rectangle. Keeping the
2374 // metadata above the image made some terminals blend text
2375 // and graphics layers during full-screen repaints; metadata
2376 // below the reserved rectangle is more stable and easier to
2377 // read. One extra blank row before the metadata gives the
2378 // caption breathing room from the image's last pixel row.
2379 //
2380 // Every footprint row (escape, reservations, gap) is tagged
2381 // with imageFootprintSentinel so callers wrapping content in
2382 // box edges can recognise these rows and emit them bare —
2383 // the image's graphics rectangle paints over them and any
2384 // │ character drawn alongside would visibly bleed through.
2385 // 4 leading cells push the escape past the box's left
2386 // edge plus a small interior gutter so the image rectangle
2387 // sits visibly inside the frame instead of kissing the │.
2388 // The escape row carries a width hint after the sentinel
2389 // ("\x1e<cells>\x1e...") so toolBoxSide knows how many
2390 // cells the image occupies and can pad to the right edge.
2391 widthHint := fmt.Sprintf("%s%d%s", imageFootprintSentinel, actualCells, imageFootprintSentinel)
2392 out := make([]string, 0, rows+3)
2393 out = append(out, widthHint+" "+seq)
2394 for i := 1; i < rows; i++ {
2395 out = append(out, imageFootprintSentinel)
2396 }
2397 out = append(out, imageFootprintSentinel)
2398 out = append(out, v.Theme.FGColor(v.Theme.Muted, info))
2399 return out
2400 }
2401 }
2402 return []string{v.Theme.FGColor(v.Theme.Muted, info)}
2403}
2404
2405// looksLikeNumberedFile returns true when text matches the `read`
2406// tool's " N\tcontent" format for most of its lines.
2407func looksLikeNumberedFile(text string) bool {
2408 lines := strings.Split(text, "\n")
2409 if len(lines) == 0 {
2410 return false
2411 }
2412 hits := 0
2413 scanned := 0
2414 for _, l := range lines {
2415 if l == "" {
2416 continue
2417 }
2418 scanned++
2419 if scanned > 20 {
2420 break
2421 }
2422 if numberedLineRE.MatchString(l) {
2423 hits++
2424 }
2425 }
2426 if scanned == 0 {
2427 return false
2428 }
2429 return hits*2 >= scanned // majority of non-empty lines match
2430}
2431
2432var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
2433
2434// renderNumberedFile strips line numbers, highlights the code, and
2435// re-attaches the line numbers in muted color.
2436func (v *View) renderNumberedFile(text, sourcePath string) []string {
2437 lines := strings.Split(text, "\n")
2438 gutters := make([]string, 0, len(lines))
2439 codes := make([]string, 0, len(lines))
2440 for _, l := range lines {
2441 idx := strings.IndexByte(l, '\t')
2442 if idx < 0 || !numberedLineRE.MatchString(l) {
2443 // Non-code footer (e.g. "[truncated at 2000 lines]").
2444 gutters = append(gutters, "")
2445 codes = append(codes, sanitizeLine(l, terminalTabStop))
2446 continue
2447 }
2448 gutter := sanitizeLine(l[:idx], 1) + " " // replace tab with single space
2449 code := sanitizeLine(l[idx+1:], codeTabStop)
2450 gutters = append(gutters, gutter)
2451 codes = append(codes, code)
2452 }
2453 lang := LanguageFromPath(sourcePath)
2454 var highlighted []string
2455 if lang != "" {
2456 highlighted = v.Theme.HighlightCode(strings.Join(codes, "\n"), lang)
2457 // Chroma sometimes collapses the trailing empty line. Pad to
2458 // align with the gutter slice so per-line zipping works.
2459 for len(highlighted) < len(codes) {
2460 highlighted = append(highlighted, "")
2461 }
2462 if len(highlighted) > len(codes) {
2463 highlighted = highlighted[:len(codes)]
2464 }
2465 } else {
2466 // No lexer for this file type — render in the ToolOut color so
2467 // code is visually distinct from the muted gutter.
2468 highlighted = make([]string, len(codes))
2469 for i, c := range codes {
2470 highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2471 }
2472 }
2473 out := make([]string, 0, len(codes))
2474 for i, code := range highlighted {
2475 g := gutters[i]
2476 if g == "" {
2477 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, code))
2478 continue
2479 }
2480 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, g)+code)
2481 }
2482 return out
2483}
2484
2485// looksLikeFileContent is a cheap guard to distinguish a read-tool
2486// result from bash stdout or a status message. File content usually
2487// contains characters that status messages don't (code punctuation,
2488// longer lines, multiple lines) and rarely starts with the " >"-
2489// or "error:"-style prefixes tools emit. False positives are OK,
2490// the worst case is a line-number gutter on something that isn't
2491// really code.
2492// renderBashResult styles a bash tool result: the "$ command" first
2493// line in the accent color, the trailing "[exit N] Took X.Ys" line
2494// in muted type, everything else on the default tool-output color.
2495// Called from renderToolText when the first line starts with "$ ".
2496func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string {
2497 for i, l := range lines {
2498 lines[i] = sanitizeLine(l, terminalTabStop)
2499 }
2500 // Identify the footer line (exit + timing). The bash tool writes
2501 // it as the last non-empty line of the result.
2502 footerIdx := -1
2503 for i := len(lines) - 1; i >= 0; i-- {
2504 trimmed := strings.TrimSpace(lines[i])
2505 if trimmed == "" {
2506 continue
2507 }
2508 if strings.HasPrefix(trimmed, "[exit ") {
2509 footerIdx = i
2510 }
2511 break
2512 }
2513
2514 var out []string
2515 for i, l := range lines {
2516 switch {
2517 case i == 0 && strings.HasPrefix(l, "$ "):
2518 // Style the $ and the command text in accent. No
2519 // further processing / wrapping so the shell-style
2520 // prompt reads at a glance.
2521 for _, w := range wrapLine(l, width-4, " ") {
2522 out = append(out, " "+v.Theme.FGColor(v.Theme.Accent, w))
2523 }
2524 case i == footerIdx:
2525 for _, w := range wrapLine(l, width-4, " ") {
2526 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
2527 }
2528 default:
2529 for _, w := range wrapLine(l, width-4, " ") {
2530 out = append(out, " "+v.Theme.FGColor(defaultColor, w))
2531 }
2532 }
2533 }
2534 return out
2535}
2536
2537// astRewriteArgs reports whether a completed AST call requested mutation.
2538func astRewriteArgs(name string, raw json.RawMessage) bool {
2539 if !strings.EqualFold(name, "ast") {
2540 return false
2541 }
2542 var args struct {
2543 Rewrite string `json:"rewrite"`
2544 }
2545 return json.Unmarshal(raw, &args) == nil && args.Rewrite != ""
2546}
2547
2548// looksLikeUnifiedDiff reports whether text is a context diff as
2549// emitted by edit and ast. Every non-empty row must start with a diff
2550// marker, except for the bounded-output truncation notice. The presence
2551// of at least one '+' or '-' row distinguishes a diff from ordinary text.
2552func looksLikeUnifiedDiff(text string) bool {
2553 lines := strings.Split(text, "\n")
2554 if len(lines) < 2 {
2555 return false
2556 }
2557 sawChange := false
2558 for _, l := range lines {
2559 if l == "" {
2560 continue
2561 }
2562 if l == "..." {
2563 continue
2564 }
2565 switch l[0] {
2566 case '+', '-':
2567 sawChange = true
2568 case ' ':
2569 // context, ok
2570 default:
2571 return false
2572 }
2573 }
2574 return sawChange
2575}
2576
2577// renderUnifiedDiff renders the edit tool's context diff. Each
2578// kept row shows a line-number gutter plus a marker column: '+'
2579// for additions (colored like add), '-' for deletions (colored
2580// like remove), and unmarked context in muted type. A literal
2581// "..." line between hunks renders as an ellipsis in muted type,
2582// indicating skipped unchanged rows. The old and new line
2583// counters advance so each row carries its actual position in
2584// the pre- or post-edit file. Heuristic: when we hit a "...", we
2585// can't know where the next hunk starts, so we don't reset the
2586// counters — they stay approximate in the rare multi-hunk case.
2587func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
2588 lines := strings.Split(text, "\n")
2589 if n := len(lines); n > 0 && lines[n-1] == "" {
2590 lines = lines[:n-1]
2591 }
2592 oldLine, newLine := 1, 1
2593 var out []string
2594 for _, l := range lines {
2595 if l == "" {
2596 out = append(out, "")
2597 continue
2598 }
2599 if l == "..." {
2600 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, "..."))
2601 continue
2602 }
2603 switch l[0] {
2604 case '+':
2605 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)...)
2606 newLine++
2607 case '-':
2608 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)...)
2609 oldLine++
2610 case ' ':
2611 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)...)
2612 oldLine++
2613 newLine++
2614 default:
2615 for _, w := range wrapLine(sanitizeLine(l, terminalTabStop), width-4, " ") {
2616 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, w))
2617 }
2618 }
2619 }
2620 return out
2621}
2622
2623func looksLikeFileContent(text string) bool {
2624 if strings.TrimSpace(text) == "" {
2625 return false
2626 }
2627 lines := strings.Split(text, "\n")
2628 return len(lines) >= 2
2629}
2630
2631// renderRawFile renders file content received without embedded line
2632// numbers (the current read-tool output). Draws a muted gutter like
2633// "%6d \t" starting at startLine, highlights the code using the
2634// source path's language, and returns the formatted lines.
2635func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
2636 lines := strings.Split(text, "\n")
2637 // Drop the trailing empty line that Split produces when text ends
2638 // in "\n" so the gutter doesn't show a phantom last number.
2639 if n := len(lines); n > 0 && lines[n-1] == "" {
2640 lines = lines[:n-1]
2641 }
2642 // Split code from trailing footer lines ("... [truncated ...]")
2643 // so we don't number the footer. A single blank separator line
2644 // immediately before the footer is also pulled out so the
2645 // gutter doesn't render a phantom number for it.
2646 codeEnd := len(lines)
2647 for i := len(lines) - 1; i >= 0; i-- {
2648 if strings.HasPrefix(lines[i], "...") {
2649 codeEnd = i
2650 continue
2651 }
2652 break
2653 }
2654 if codeEnd > 0 && codeEnd < len(lines) && lines[codeEnd-1] == "" {
2655 codeEnd--
2656 }
2657 code := lines[:codeEnd]
2658 footer := lines[codeEnd:]
2659
2660 for i, c := range code {
2661 code[i] = sanitizeLine(c, codeTabStop)
2662 }
2663 for i, f := range footer {
2664 footer[i] = sanitizeLine(f, terminalTabStop)
2665 }
2666
2667 lang := LanguageFromPath(sourcePath)
2668 var highlighted []string
2669 if lang != "" {
2670 highlighted = v.Theme.HighlightCode(strings.Join(code, "\n"), lang)
2671 for len(highlighted) < len(code) {
2672 highlighted = append(highlighted, "")
2673 }
2674 if len(highlighted) > len(code) {
2675 highlighted = highlighted[:len(code)]
2676 }
2677 } else {
2678 highlighted = make([]string, len(code))
2679 for i, c := range code {
2680 highlighted[i] = v.Theme.FGColor(v.Theme.ToolOut, c)
2681 }
2682 }
2683
2684 out := make([]string, 0, len(lines))
2685 for i, c := range highlighted {
2686 gutter := fmt.Sprintf("%4d ", startLine+i)
2687 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, gutter)+c)
2688 }
2689 for _, f := range footer {
2690 out = append(out, " "+v.Theme.FGColor(v.Theme.Muted, f))
2691 }
2692 return out
2693}
2694
2695// defaultToolArgWidth is the number of cells the tool-call header
2696// truncates the primary argument (path/command/query) to when the
2697// ZUT_TOOL_ARG_WIDTH environment variable is unset or invalid.
2698const defaultToolArgWidth = 60
2699
2700// toolArgWidth returns the maximum cell width for the truncated
2701// primary argument shown in a tool-call header. It defaults to
2702// defaultToolArgWidth but can be raised or lowered with the
2703// ZUT_TOOL_ARG_WIDTH environment variable, which is useful for wide
2704// terminals where the default clips long queries too aggressively.
2705// Values outside the sane range [20, 500] are ignored so a stray or
2706// malformed setting can never produce an unreadable header or an
2707// out-of-range slice.
2708func toolArgWidth() int {
2709 v := strings.TrimSpace(os.Getenv("ZUT_TOOL_ARG_WIDTH"))
2710 if v == "" {
2711 return defaultToolArgWidth
2712 }
2713 n, err := strconv.Atoi(v)
2714 if err != nil || n < 20 || n > 500 {
2715 return defaultToolArgWidth
2716 }
2717 return n
2718}
2719
2720// ShortArgs renders a tool call's arguments into a one-line
2721// suffix for the "tool name <args>" header. tool is the tool
2722// name so we can add shape-specific decorations: for read we
2723// append the requested line range (e.g. "path:1-200") pulled
2724// from the offset/limit args, and for glob we prefer the pattern
2725// with an " in <dir>" suffix. Other tools keep the legacy
2726// "path, file_path, or command, truncated" shape.
2727//
2728// The truncation width defaults to 60 cells but can be tuned via
2729// the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).
2730//
2731// Exported because the interactive mode pre-populates the
2732// ToolCallView.Args field with this value as soon as the tool
2733// call is announced, so the live overlay's header matches what
2734// the finalised transcript will later render.
2735func ShortArgs(tool string, raw json.RawMessage) string {
2736 width := toolArgWidth()
2737 var v any
2738 if err := json.Unmarshal(raw, &v); err != nil {
2739 return ""
2740 }
2741 x, ok := v.(map[string]any)
2742 if !ok {
2743 b, _ := json.Marshal(v)
2744 s := oneLineToolLabel(string(b))
2745 if len(s) > width {
2746 s = s[:width-3] + "..."
2747 }
2748 return s
2749 }
2750 var primary string
2751 // Glob leads with its pattern; every other tool keeps the legacy
2752 // path-first shape so tools carrying both (e.g. ast's pattern+path)
2753 // still label with the file path.
2754 keys := []string{"path", "file_path", "command"}
2755 if strings.EqualFold(tool, "glob") {
2756 keys = []string{"pattern", "path", "file_path", "command"}
2757 }
2758 for _, k := range keys {
2759 if s, ok := x[k].(string); ok {
2760 primary = s
2761 break
2762 }
2763 }
2764 if primary == "" {
2765 b, _ := json.Marshal(v)
2766 s := oneLineToolLabel(string(b))
2767 if len(s) > width {
2768 s = s[:width-3] + "..."
2769 }
2770 return s
2771 }
2772 primary = oneLineToolLabel(primary)
2773
2774 // Tool-specific decoration.
2775 suffix := ""
2776 switch strings.ToLower(tool) {
2777 case "read":
2778 start := 1
2779 if n, ok := toInt(x["offset"]); ok && n >= 1 {
2780 start = n
2781 }
2782 if lim, ok := toInt(x["limit"]); ok && lim > 0 {
2783 end := start + lim - 1
2784 suffix = fmt.Sprintf(":%d-%d", start, end)
2785 } else if start > 1 {
2786 suffix = fmt.Sprintf(":%d-", start)
2787 }
2788 case "glob":
2789 if dir, ok := x["path"].(string); ok && dir != "" && dir != "." {
2790 suffix = " in " + dir
2791 }
2792 }
2793
2794 // Truncate the primary arg leaving room for the suffix so the
2795 // range stays visible even on absurdly long paths.
2796 max := width - len(suffix)
2797 if max < 10 {
2798 max = 10
2799 }
2800 if len(primary) > max {
2801 primary = primary[:max-3] + "..."
2802 }
2803 return primary + suffix
2804}
2805
2806// toInt coerces a json.Unmarshal'd number (float64) or a string
2807// containing a number into an int. Returns ok=false if the value
2808// is neither. Used by shortArgs to survive model quirks where
2809// numeric args come back as strings.
2810func toInt(v any) (int, bool) {
2811 switch n := v.(type) {
2812 case float64:
2813 return int(n), true
2814 case int:
2815 return n, true
2816 case string:
2817 i, err := strconv.Atoi(strings.TrimSpace(n))
2818 if err != nil {
2819 return 0, false
2820 }
2821 return i, true
2822 }
2823 return 0, false
2824}
2825
2826// renderCompactionBlock renders a compaction summary as a distinct
2827// visual block in the chat. When collapsed it shows a one-line label
2828// with the pre-compaction token count; when expanded (ctrl+o) it
2829// shows the full summary text.
2830func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
2831 th := v.Theme
2832 const indent = " "
2833
2834 tokens := m.Meta["tokens_before"]
2835 if tokens == "" {
2836 tokens = "?"
2837 }
2838
2839 if v.ExpandAll {
2840 var lines []string
2841 header := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
2842 lines = append(lines, indent+header)
2843 lines = append(lines, "")
2844 for _, c := range m.Content {
2845 if tb, ok := c.(provider.TextBlock); ok {
2846 text := tb.Text
2847 if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
2848 text = text[idx+2:]
2849 }
2850 md := RenderMarkdown(text, th, width-4)
2851 for _, l := range strings.Split(md, "\n") {
2852 if len(l) > 0 && l[0] == FlushLeftSentinel {
2853 l = l[1:]
2854 }
2855 lines = append(lines, indent+l)
2856 }
2857 }
2858 }
2859 return lines
2860 }
2861
2862 // Collapsed: single line, no banner.
2863 line := th.FGColor(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
2864 return []string{indent + line}
2865}
2866
2867// StatusBarParams groups the many bits of state the status bar needs.
2868// Grew from a flat argument list once we settled on the layout.
2869type StatusBarParams struct {
2870 Theme Theme
2871 Provider string
2872 Model string
2873 Reasoning string // "" means thinking off
2874 ModelProfile int // 1–9 identifies the active model/reasoning favorite
2875 FastMode bool // show the provider's opt-in fast tier when enabled
2876 Busy bool
2877 BusyPrefix string // spinner + funny line when busy
2878 CWD string
2879 Locked bool // sandbox on?
2880 NoYolo bool // confirmation mode enabled?
2881 GoalStatus string // autonomous goal lifecycle, empty when none
2882 PlanCurrent int // one-based in-progress plan step; 0 hides plan progress
2883 PlanTotal int
2884
2885 // Cumulative session usage and cost.
2886 Usage provider.Usage
2887 // Subscription is true when the credential is an OAuth token (claude
2888 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
2889 // compute an API-equivalent cost for visibility, not an actual charge.
2890 Subscription bool
2891
2892 // WeeklyUsage is the account's remaining weekly allowance, already
2893 // formatted by the caller (empty for providers without this meter).
2894 WeeklyUsage string
2895
2896 // Last turn's input+cache tokens (approximates current live context).
2897 ContextUsed int
2898 ContextMax int // model's context window; 0 disables the percentage
2899
2900 // AutoCompacting is true when the agent is currently running a
2901 // model-triggered condense pass. Surfaces as "(auto)" after the
2902 // context percentage so it's clear where the spinner is coming from.
2903 AutoCompacting bool
2904
2905 // Telegram true when the telegram bridge is connected. Adds a
2906 // small "- tg -" tag to the cwd line so the user can tell at a
2907 // glance that dms are being mirrored into this session.
2908 Telegram bool
2909
2910 Cols int // terminal width; drives status wrapping
2911}
2912
2913// StatusBar builds the status shown above the editor. The cwd stays on its
2914// own final line, indented to match the stats column. Narrow terminals split
2915// model and usage onto additional lines rather than hard-wrapping metrics.
2916//
2917// Layout:
2918//
2919// <busyPrefix> model:reasoning stats <- line 1
2920// cwd <- line 2 (2-space indent)
2921//
2922// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
2923// The slash-command popup and the queued/sliding-in chips already
2924// cover the discoverability of those keybindings.
2925func StatusBar(p StatusBarParams) []string {
2926 th := p.Theme
2927
2928 stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})
2929 for idx, stat := range stats {
2930 stats[idx] = th.FGColor(th.Muted, stat)
2931 }
2932 if p.WeeklyUsage != "" {
2933 if label, value, ok := strings.Cut(p.WeeklyUsage, ":"); ok {
2934 stats = append(stats, statusLabelValue(th, label+":", value, th.FG))
2935 } else {
2936 stats = append(stats, th.FGColor(th.Muted, p.WeeklyUsage))
2937 }
2938 }
2939 if p.GoalStatus != "" {
2940 stats = append(stats, statusLabelValue(th, "goal:", p.GoalStatus, th.FG))
2941 }
2942 if p.PlanCurrent > 0 && p.PlanTotal > 0 {
2943 stats = append(stats, statusLabelValue(th, "plan:", fmt.Sprintf("%d/%d", p.PlanCurrent, p.PlanTotal), th.FG))
2944 }
2945
2946 // Context %. Color-coded: yellow >70, red >90.
2947 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
2948 if ctx != "" {
2949 if ctxColor == th.Muted {
2950 ctxColor = th.FG
2951 }
2952 value := ctx
2953 suffix := ""
2954 if p.ContextMax > 0 {
2955 value = fmt.Sprintf("%.0f%%", float64(p.ContextUsed)/float64(p.ContextMax)*100)
2956 suffix = "/" + formatTokens(p.ContextMax)
2957 }
2958 if p.AutoCompacting {
2959 suffix += " (auto)"
2960 }
2961 stats = append(stats, statusLabelValueSuffix(th, "ctx", value, suffix, ctxColor))
2962 }
2963
2964 // Layout uses exactly 2 spaces of horizontal padding everywhere:
2965 // 2 spaces gpt-5.4:medium $0.00 ctx0%/400k
2966 // matches the editor prompt's left inset so the bar lines up
2967 // vertically with the conversation column.
2968 const pad = " " // 2 spaces
2969
2970 reasoning := reasoningLevelLabel(p.Reasoning)
2971 modelLabel := p.Model
2972 if p.ModelProfile >= 1 && p.ModelProfile <= 9 {
2973 modelLabel = fmt.Sprintf("[%d] %s", p.ModelProfile, modelLabel)
2974 }
2975 left := renderStatusModel(th, modelLabel, reasoning)
2976 fastText := ""
2977 if p.FastMode {
2978 fastText = th.FGColor(th.Muted, "fast mode")
2979 }
2980 statsText := strings.Join(stats, " ")
2981 middleParts := make([]string, 0, 3)
2982 if fastText != "" {
2983 middleParts = append(middleParts, fastText)
2984 }
2985 if statsText != "" {
2986 middleParts = append(middleParts, statsText)
2987 }
2988 middle := strings.Join(middleParts, " ")
2989
2990 var leftBuilder strings.Builder
2991 if p.BusyPrefix != "" {
2992 // Don't re-color the payload itself — the caller already set
2993 // fg colors on the spinner glyph, message, and elapsed
2994 // segments. Wrapping the whole thing here would override
2995 // those choices. The pad itself needs no color (it's spaces).
2996 leftBuilder.WriteString(pad + p.BusyPrefix)
2997 // Exactly one pad (2 spaces) between the busy segment and
2998 // the model block. The leading pad above covers
2999 // the left indent.
3000 leftBuilder.WriteString(pad)
3001 } else {
3002 // Idle path: a single pad of left inset so the line
3003 // aligns with the conversation column on its left edge
3004 // (" you" / " zut" message markers). Without the busy
3005 // prefix there's no trailing separator to double-pad.
3006 leftBuilder.WriteString(pad)
3007 }
3008 leftBuilder.WriteString(left)
3009 if middle != "" {
3010 leftBuilder.WriteString(pad)
3011 leftBuilder.WriteString(middle)
3012 }
3013
3014 cwd := shortenHome(p.CWD)
3015 tags := ""
3016 if p.NoYolo {
3017 tags += "yolo mode disabled "
3018 }
3019 if p.Locked {
3020 tags += "jailed "
3021 }
3022 if p.Telegram {
3023 tags += "telegram connected "
3024 }
3025 if tags != "" && cwd != "" {
3026 cwd = tags + "- " + cwd
3027 }
3028
3029 primary := leftBuilder.String()
3030
3031 // On narrow terminals the single line wraps badly. If the visible
3032 // width exceeds cols and we have a busy prefix, split: keep the
3033 // busy prefix on line 1, then put model and (if still needed)
3034 // stats on their own rows. This mirrors the idle split below.
3035 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
3036 busyLine := pad + p.BusyPrefix
3037 modelLine := pad + left
3038 lines := []string{busyLine}
3039 if visibleWidth(modelLine+pad+middle) > p.Cols {
3040 lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols)
3041 } else {
3042 var infoBuilder strings.Builder
3043 infoBuilder.WriteString(modelLine)
3044 if middle != "" {
3045 infoBuilder.WriteString(pad)
3046 infoBuilder.WriteString(middle)
3047 }
3048 lines = append(lines, infoBuilder.String())
3049 }
3050 if cwd != "" {
3051 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3052 }
3053 return lines
3054 }
3055
3056 // Idle narrow split: keep model/reasoning on the first status line,
3057 // move usage/cost/context stats to the next, then cwd below. This
3058 // avoids the terminal's hard wrap cutting the stats or pushing cwd
3059 // into an awkward position on small widths.
3060 if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
3061 var lines []string
3062 lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols)
3063 if cwd != "" {
3064 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3065 }
3066 return lines
3067 }
3068
3069 if cwd == "" {
3070 return []string{primary}
3071 }
3072
3073 // Second line: indent with the same 2-space pad so the cwd lines
3074 // up under the model column on line 1.
3075 cwdRendered := pad + th.FGColor(th.Muted, cwd)
3076 return []string{primary, cwdRendered}
3077}
3078
3079func statusLabelValue(th Theme, label, value string, valueColor TerminalColor) string {
3080 return statusLabelValueSuffix(th, label, value, "", valueColor)
3081}
3082
3083func statusLabelValueSuffix(th Theme, label, value, suffix string, valueColor TerminalColor) string {
3084 var rendered strings.Builder
3085 if label != "" {
3086 rendered.WriteString(th.FGColor(th.Muted, label))
3087 }
3088 if value != "" {
3089 rendered.WriteString(th.FGColor(valueColor, value))
3090 }
3091 if suffix != "" {
3092 rendered.WriteString(th.FGColor(th.Muted, suffix))
3093 }
3094 return rendered.String()
3095}
3096
3097func renderStatusModel(th Theme, model, reasoning string) string {
3098 rendered := th.FGColor(th.Muted, model)
3099 if reasoning == "" {
3100 return rendered
3101 }
3102 valueColor := th.FG
3103 if reasoning == "max" {
3104 valueColor = th.ThinkingMax
3105 }
3106 return rendered + statusLabelValue(th, ":", reasoning, valueColor)
3107}
3108
3109func appendWrappedStatusLines(lines []string, th Theme, pad, model, reasoning, fastText string, stats []string, cols int) []string {
3110 modelLine := pad + renderStatusModel(th, model, reasoning)
3111 if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {
3112 modelLine += pad + fastText
3113 } else if fastText != "" {
3114 stats = append([]string{fastText}, stats...)
3115 }
3116 lines = append(lines, truncateToWidth(modelLine, cols))
3117 return appendWrappedUsageStats(lines, th, pad, stats, cols)
3118}
3119
3120func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string {
3121 if len(stats) == 0 {
3122 return lines
3123 }
3124 available := cols - visibleWidth(pad)
3125 if cols <= 0 || available <= 0 {
3126 return append(lines, pad+strings.Join(stats, " "))
3127 }
3128 current := make([]string, 0, len(stats))
3129 flush := func() {
3130 if len(current) == 0 {
3131 return
3132 }
3133 line := pad + strings.Join(current, " ")
3134 lines = append(lines, truncateToWidth(line, cols))
3135 current = current[:0]
3136 }
3137 for _, stat := range stats {
3138 candidate := append(append([]string(nil), current...), stat)
3139 if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available {
3140 flush()
3141 }
3142 current = append(current, stat)
3143 }
3144 flush()
3145 return lines
3146}
3147
3148func reasoningLevelLabel(level string) string {
3149 switch strings.ToLower(strings.TrimSpace(level)) {
3150 case "", "off", "none", "no", "false", "disabled":
3151 return ""
3152 case "minimum", "minimal", "min":
3153 return "minimal"
3154 case "maximum", "xhigh":
3155 return "xhigh"
3156 case "max":
3157 return "max"
3158 default:
3159 return strings.ToLower(strings.TrimSpace(level))
3160 }
3161}
3162
3163// contextUsage renders the "N%/ctxMax" fragment, returning the
3164// rendered string plus the colour to wrap it in.
3165func contextUsage(th Theme, used, max int) (string, TerminalColor) {
3166 if max <= 0 {
3167 if used <= 0 {
3168 return "", th.Muted
3169 }
3170 return formatTokens(used), th.Muted
3171 }
3172 pct := float64(used) / float64(max) * 100
3173 text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
3174 switch {
3175 case pct > 90:
3176 return text, th.Error
3177 case pct > 70:
3178 return text, th.Warning
3179 }
3180 return text, th.Muted
3181}
3182
3183// formatTokens footer formatter:
3184//
3185// < 1000 -> "42"
3186// < 10_000 -> "2.7k"
3187// < 1_000_000 -> "35k"
3188// < 10M -> "1.1M"
3189// else -> "12M"
3190func formatTokens(n int) string {
3191 switch {
3192 case n < 0:
3193 return "0"
3194 case n < 1000:
3195 return fmt.Sprintf("%d", n)
3196 case n < 10000:
3197 return fmt.Sprintf("%.1fk", float64(n)/1000)
3198 case n < 1_000_000:
3199 return fmt.Sprintf("%dk", (n+500)/1000)
3200 case n < 10_000_000:
3201 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
3202 default:
3203 return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
3204 }
3205}
3206
3207// shortenHome replaces the user's $HOME prefix with "~" for readability.
3208func shortenHome(p string) string {
3209 if p == "" {
3210 return ""
3211 }
3212 home := osUserHome()
3213 if home == "" {
3214 return p
3215 }
3216 if p == home {
3217 return "~"
3218 }
3219 if strings.HasPrefix(p, home+"/") {
3220 return "~" + p[len(home):]
3221 }
3222 return p
3223}
3224
3225// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
3226var osUserHome = func() string {
3227 if h, err := osUserHomeDir(); err == nil {
3228 return h
3229 }
3230 return ""
3231}