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