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