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