1package tui
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "github.com/patriceckhart/zot/internal/provider"
12)
13
14// expandTabs replaces tab characters with 4 spaces so code from
15// tab-indented languages (Go, Makefiles, etc.) renders at a
16// consistent width instead of the terminal's default 8-column tabs.
17func expandTabs(s string) string {
18 return strings.ReplaceAll(s, "\t", " ")
19}
20
21// pathFromToolArgs returns the "path" argument from a tool_call's
22// JSON arguments, or "" if the args aren't a JSON object or don't
23// include one. Used to pick a syntax language for rendering the
24// corresponding tool_result.
25func pathFromToolArgs(raw json.RawMessage) string {
26 if len(raw) == 0 {
27 return ""
28 }
29 var m map[string]any
30 if err := json.Unmarshal(raw, &m); err != nil {
31 return ""
32 }
33 for _, k := range []string{"path", "file_path"} {
34 if s, ok := m[k].(string); ok && s != "" {
35 return s
36 }
37 }
38 return ""
39}
40
41// offsetFromToolArgs returns the read tool's 1-indexed `offset`
42// arg (the first line of the slice the tool was asked to return),
43// or 0 when the call didn't specify one. Used by the tui to draw
44// the line-number gutter aligned to the right starting row, even
45// though the tool's text content itself no longer carries line
46// numbers.
47func offsetFromToolArgs(raw json.RawMessage) int {
48 if len(raw) == 0 {
49 return 0
50 }
51 var m map[string]any
52 if err := json.Unmarshal(raw, &m); err != nil {
53 return 0
54 }
55 switch v := m["offset"].(type) {
56 case float64:
57 return int(v)
58 case int:
59 return v
60 }
61 return 0
62}
63
64// osUserHomeDir is aliased so the test file can swap it.
65var osUserHomeDir = os.UserHomeDir
66
67// View turns a transcript + live state into a slice of styled lines,
68// already wrapped to width.
69type View struct {
70 Theme Theme
71 ImageProto ImageProtocol // how to render inline images in this terminal
72 Messages []provider.Message
73 // toolPaths maps tool_use_id to the "path" argument of the call, if
74 // any, so tool_result rendering can pick the right syntax language.
75 // Rebuilt on each Build().
76 toolPaths map[string]string
77 // toolStartLines maps tool_use_id to the 1-indexed first line
78 // number of a `read` result, pulled from the call's offset arg.
79 // Used by renderNumberedFile to draw a line-number gutter over
80 // raw (unnumbered) file content the model receives. Rebuilt on
81 // each Build().
82 toolStartLines map[string]int
83 Streaming string // current assistant text delta
84 StreamingActive bool
85 ToolCalls []ToolCallView // tool calls in flight or completed
86 StatusLine string
87 Err string
88
89 // ExpandAll forces every long tool result to render in full.
90 // Toggled from the tui by ctrl+o. When false, results longer than
91 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
92 // "... (N more lines, M total, ctrl+o to expand)" footer.
93 ExpandAll bool
94
95 // renderCache holds the per-message rendered line slices so Build
96 // doesn't re-markdown every message on every frame. Keyed by a
97 // struct of (content hash, width, expandAll) — any of those
98 // changing invalidates the entry. Messages are append-only after
99 // they finalise so keeping the cache across turns is safe.
100 //
101 // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
102 // cached because it changes every delta.
103 renderCache map[msgCacheKey][]string
104}
105
106// msgCacheKey identifies a cached message render. hash is a 64-bit
107// FNV-1a of the message's content, which is cheap to compute and
108// unambiguous enough for the cache (collisions produce a stale frame,
109// not wrong data, and we recompute on invalidation anyway).
110type msgCacheKey struct {
111 hash uint64
112 width int
113 expandAll bool
114}
115
116// InvalidateRenderCache drops all cached message renders. The tui
117// calls this when the transcript is replaced wholesale (/compact,
118// /clear, session swap) since messages can be replaced in place and
119// a content-hash miss alone doesn't reclaim the old entries.
120func (v *View) InvalidateRenderCache() {
121 v.renderCache = nil
122}
123
124// ToolCollapsePreview is the number of lines shown before a long tool
125// result is replaced with a "... ctrl+o to expand" footer. Tool
126// results shorter than ToolCollapseLines always render in full.
127const (
128 ToolCollapsePreview = 10
129 ToolCollapseLines = 12
130)
131
132// ToolCallView is a pending tool invocation plus optional result.
133type ToolCallView struct {
134 ID string
135 Name string
136 Args string // rendered argument summary
137 Result string // rendered result preview (truncated)
138 Error bool
139 Done bool
140
141 // Streaming is true while the model is still typing the tool
142 // call's JSON arguments. The TUI renders a live preview of any
143 // interesting string fields (for `write`, the `content`; for
144 // `bash`, the `command`) so the user can watch the file being
145 // composed. Set to false as soon as EvToolUseEnd arrives.
146 Streaming bool
147
148 // RawJSONBuf is the accumulator of every EvToolUseArgs delta
149 // the stream has delivered for this tool call. Used by the
150 // partial-JSON extractor to peel off the live string value
151 // of one named field on each render.
152 RawJSONBuf string
153
154 // LivePath is the `path` arg extracted as soon as it parses
155 // out of RawJSONBuf. Shown next to the tool name in the header
156 // so the user can see which file is being written to.
157 LivePath string
158}
159
160// MessageAnchor records where a rendered message starts in the chat
161// line slice. Used by /jump so the dialog can scroll the viewport to
162// the row where a turn's user prompt begins.
163type MessageAnchor struct {
164 MessageIdx int // index into v.Messages
165 Row int // first row of that message in the Build() output
166}
167
168// Build returns the chat log lines for the given width.
169func (v *View) Build(width int) []string {
170 lines, _ := v.BuildWithAnchors(width)
171 return lines
172}
173
174// BuildWithAnchors is like Build but additionally reports the first
175// row occupied by each message in v.Messages. Callers that need to
176// scroll to a specific turn (the /jump dialog) use the anchor slice
177// to map a message index back to a row offset.
178func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
179 v.refreshToolPaths()
180 if v.renderCache == nil {
181 v.renderCache = make(map[msgCacheKey][]string)
182 }
183
184 // Pre-render every message (hits the cache for unchanged ones) so
185 // we can allocate `out` in a single shot with the exact capacity.
186 // Growing via append on a long transcript copies the backing array
187 // log2(N) times; for a 2000-line scrollback that's enough memcpy
188 // to visibly stutter while typing.
189 rendered := make([][]string, len(v.Messages))
190 total := 0
191 for idx, m := range v.Messages {
192 lines := v.renderMessageCached(m, width)
193 rendered[idx] = lines
194 total += len(lines) + 1 // +1 for the blank separator row
195 }
196
197 out := make([]string, 0, total+16)
198 anchors := make([]MessageAnchor, 0, len(v.Messages))
199 for idx := range v.Messages {
200 anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
201 out = append(out, rendered[idx]...)
202 out = append(out, "")
203 }
204 // Only render the streaming header/body when there's actual
205 // text to show. An empty streaming block (streamOn=true,
206 // Streaming="") appears when a turn starts with a tool_use
207 // block instead of text — in that case the live tool-call
208 // overlay below is the real content and a naked "zot" bar
209 // above it reads as a stray empty message.
210 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
211 out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
212 // Stream the partial assistant text through the same markdown
213 // renderer used for finalised messages so code fences, diffs,
214 // lists, and inline styles look the same while streaming and
215 // don't suddenly reflow when the turn ends. Indent matches the
216 // finalised assistant body in renderMessage so the column
217 // stays consistent across the stream/finalise transition.
218 // Width is capped so ultra-wide terminals don't produce
219 // edge-to-edge rules / unreadably long prose lines.
220 const indent = " "
221 inner := assistantBodyWidth(width - len(indent))
222 md := RenderMarkdown(v.Streaming, v.Theme, inner)
223 for _, l := range strings.Split(md, "\n") {
224 for _, w := range wrapANSILine(l, inner) {
225 out = append(out, indent+w)
226 }
227 }
228 out = append(out, "")
229 }
230 // Live tool-call overlay: skip any entry whose assistant
231 // tool_use block OR tool_result has already made it into the
232 // transcript. The EvAssistantMessage for a tool-use turn
233 // lands BEFORE executeTools runs, so between that moment and
234 // the tool-result being appended the overlay and the
235 // finalised transcript both render the same call. Checking
236 // for either side of the pair suppresses the duplicate in
237 // both windows.
238 finalised := map[string]bool{}
239 for _, m := range v.Messages {
240 for _, c := range m.Content {
241 switch b := c.(type) {
242 case provider.ToolCallBlock:
243 finalised[b.ID] = true
244 case provider.ToolResultBlock:
245 finalised[b.CallID] = true
246 }
247 }
248 }
249 for _, tc := range v.ToolCalls {
250 if finalised[tc.ID] {
251 continue
252 }
253 out = append(out, v.renderToolCall(tc, width)...)
254 out = append(out, "")
255 }
256 if v.Err != "" {
257 out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
258 out = append(out, "")
259 }
260 return out, anchors
261}
262
263// refreshToolPaths rebuilds the tool_use_id -> path map from the
264// current transcript. Called once per Build() so tool result blocks
265// (which may be cached) can look up their syntax language when they
266// were originally rendered. Walking the transcript here is O(N) but
267// cheap compared to markdown/chroma work it enables.
268func (v *View) refreshToolPaths() {
269 v.toolPaths = map[string]string{}
270 v.toolStartLines = map[string]int{}
271 for _, m := range v.Messages {
272 for _, c := range m.Content {
273 if tc, ok := c.(provider.ToolCallBlock); ok {
274 if p := pathFromToolArgs(tc.Arguments); p != "" {
275 v.toolPaths[tc.ID] = p
276 }
277 if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
278 v.toolStartLines[tc.ID] = off
279 }
280 }
281 }
282 }
283}
284
285// renderMessageCached returns the rendered line slice for m, using the
286// cache if the same (content hash, width, expandAll) combination has
287// been rendered before. The slice returned is shared — callers must
288// not mutate it; Build() only ever appends to its own `out` so the
289// shared slice is safe.
290func (v *View) renderMessageCached(m provider.Message, width int) []string {
291 key := msgCacheKey{
292 hash: hashMessage(m),
293 width: width,
294 expandAll: v.ExpandAll,
295 }
296 if v.renderCache != nil {
297 if lines, ok := v.renderCache[key]; ok {
298 return lines
299 }
300 }
301 lines := v.renderMessage(m, width)
302 if v.renderCache != nil {
303 // Bound the cache: 4x the current message count is enough to
304 // survive /compact churn without leaking memory across a very
305 // long session.
306 max := len(v.Messages) * 4
307 if max < 32 {
308 max = 32
309 }
310 if len(v.renderCache) > max {
311 // Drop half the entries. map iteration order gives us a
312 // pseudo-LRU for free.
313 dropped := 0
314 target := len(v.renderCache) / 2
315 for k := range v.renderCache {
316 if dropped >= target {
317 break
318 }
319 delete(v.renderCache, k)
320 dropped++
321 }
322 }
323 v.renderCache[key] = lines
324 }
325 return lines
326}
327
328// hashMessage returns a 64-bit FNV-1a over the role + content blocks
329// of m. Serialising each block to its salient bytes is enough: two
330// messages with the same role and same content render identically.
331func hashMessage(m provider.Message) uint64 {
332 h := fnv64aInit
333 h = fnv64aWrite(h, []byte(m.Role))
334 h = fnv64aWriteByte(h, 0)
335 for _, c := range m.Content {
336 switch b := c.(type) {
337 case provider.TextBlock:
338 h = fnv64aWriteByte(h, 't')
339 h = fnv64aWrite(h, []byte(b.Text))
340 case provider.ImageBlock:
341 h = fnv64aWriteByte(h, 'i')
342 h = fnv64aWrite(h, []byte(b.MimeType))
343 h = fnv64aWrite(h, b.Data)
344 case provider.ToolCallBlock:
345 h = fnv64aWriteByte(h, 'c')
346 h = fnv64aWrite(h, []byte(b.ID))
347 h = fnv64aWrite(h, []byte(b.Name))
348 h = fnv64aWrite(h, []byte(b.Arguments))
349 case provider.ToolResultBlock:
350 h = fnv64aWriteByte(h, 'r')
351 h = fnv64aWrite(h, []byte(b.CallID))
352 if b.IsError {
353 h = fnv64aWriteByte(h, 'E')
354 }
355 for _, inner := range b.Content {
356 switch ib := inner.(type) {
357 case provider.TextBlock:
358 h = fnv64aWrite(h, []byte(ib.Text))
359 case provider.ImageBlock:
360 h = fnv64aWrite(h, []byte(ib.MimeType))
361 h = fnv64aWrite(h, ib.Data)
362 }
363 }
364 }
365 h = fnv64aWriteByte(h, 0)
366 }
367 return h
368}
369
370// FNV-1a implementation inlined so we don't pay the interface cost of
371// hash.Hash64 on every Build(). The whole point here is speed.
372const (
373 fnv64aInit uint64 = 0xcbf29ce484222325
374 fnv64aPrime uint64 = 0x100000001b3
375)
376
377// assistantBodyRightPad is the blank gutter kept on the right
378// side of every assistant prose line so text doesn't kiss the
379// terminal edge. Matches the 4-cell left indent, so a line of
380// fully-wrapped prose sits in a symmetric column.
381const assistantBodyRightPad = 4
382
383// assistantBodyWidth returns the usable width for the assistant
384// message body (markdown prose + code fences). Uses the full
385// column width passed in minus assistantBodyRightPad, clamped
386// at 1 so wrap helpers don't divide by zero on absurdly narrow
387// terminals. The right-side padding keeps a small breathing
388// column to the terminal edge that mirrors the left indent.
389func assistantBodyWidth(outer int) int {
390 outer -= assistantBodyRightPad
391 if outer < 1 {
392 return 1
393 }
394 return outer
395}
396
397func fnv64aWriteByte(h uint64, b byte) uint64 {
398 h ^= uint64(b)
399 h *= fnv64aPrime
400 return h
401}
402
403func fnv64aWrite(h uint64, p []byte) uint64 {
404 for _, b := range p {
405 h ^= uint64(b)
406 h *= fnv64aPrime
407 }
408 return h
409}
410
411func (v *View) renderMessage(m provider.Message, width int) []string {
412 var lines []string
413
414 // Compaction summary: render as a single muted line at the end
415 // of the chat instead of as a user message.
416 if m.Meta["compaction"] == "true" {
417 if v.ExpandAll {
418 return v.renderCompactionBlock(m, width)
419 }
420 // Collapsed: skip entirely. The status bar shows the info.
421 return nil
422 }
423
424 switch m.Role {
425 case provider.RoleUser:
426 header := v.Theme.FG256(v.Theme.User, "▍ you")
427 lines = append(lines, header)
428 for _, c := range m.Content {
429 switch b := c.(type) {
430 case provider.TextBlock:
431 for _, l := range strings.Split(b.Text, "\n") {
432 for _, w := range wrapLine(l, width-4, "") {
433 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
434 }
435 }
436 case provider.ImageBlock:
437 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
438 }
439 }
440 case provider.RoleAssistant:
441 header := v.Theme.FG256(v.Theme.Assistant, "▍ zot")
442 lines = append(lines, header)
443 // Indent assistant body the same 4 cells the user body uses,
444 // so the conversation column lines up vertically. The width
445 // passed into the markdown renderer / wrap is reduced by the
446 // indent so long lines wrap inside the indented column, and
447 // capped so ultra-wide terminals don't produce edge-to-edge
448 // code-fence rules or unreadably long prose lines.
449 const indent = " "
450 inner := assistantBodyWidth(width - len(indent))
451 for _, c := range m.Content {
452 switch b := c.(type) {
453 case provider.TextBlock:
454 md := RenderMarkdown(strings.TrimLeft(b.Text, "\n"), v.Theme, inner)
455 for _, l := range strings.Split(md, "\n") {
456 for _, w := range wrapANSILine(l, inner) {
457 lines = append(lines, indent+w)
458 }
459 }
460 case provider.ToolCallBlock:
461 // Rule above the tool header frames the call as a
462 // self-contained block separating it from the
463 // assistant prose above. The matching closing rule
464 // is emitted at the end of the tool-role message.
465 lines = append(lines, toolBlockRule(v.Theme, width))
466 lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
467 }
468 }
469 case provider.RoleTool:
470 for _, c := range m.Content {
471 if tr, ok := c.(provider.ToolResultBlock); ok {
472 color := v.Theme.ToolOut
473 if tr.IsError {
474 color = v.Theme.Error
475 }
476 path := ""
477 if v.toolPaths != nil {
478 path = v.toolPaths[tr.CallID]
479 }
480 startLine := 1
481 if v.toolStartLines != nil {
482 if s := v.toolStartLines[tr.CallID]; s > 0 {
483 startLine = s
484 }
485 }
486 // Render the body directly under the tool-call
487 // header (no "result" sub-header). Errors keep a
488 // one-line header so they're distinguishable from
489 // successful output. A closing rule below the body
490 // pairs with the opening rule emitted above the
491 // tool-call header in the assistant message,
492 // framing the whole tool block.
493 if tr.IsError {
494 lines = append(lines, v.Theme.FG256(color, " error"))
495 }
496 lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)
497 lines = append(lines, toolBlockRule(v.Theme, width))
498 }
499 }
500 }
501 return lines
502}
503
504func (v *View) renderToolCall(tc ToolCallView, width int) []string {
505 var lines []string
506
507 // Header. While the call is still streaming, prefer the live path
508 // extracted from the partial args so the user sees the target
509 // file as soon as it's known, even before the full JSON arrived.
510 arg := tc.Args
511 if arg == "" && tc.LivePath != "" {
512 arg = tc.LivePath
513 }
514 head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg)
515
516 // Live streaming body: pulled out of the partial JSON buffer for
517 // tools whose interesting content is a string field (currently
518 // write's `content` and edit's `new_text` chunks). The body is
519 // already framed by wrapLiveBody with top+bottom rules, so we
520 // don't add the extra toolBlockRule around it — that would
521 // produce four rules per streaming block, with a visible doubled
522 // line at the bottom.
523 if tc.Streaming && tc.Result == "" {
524 lines = append(lines, head)
525 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
526 lines = append(lines, body...)
527 }
528 return lines
529 }
530
531 // Finished tool call: frame the whole block with opening +
532 // closing rules so it stands apart from surrounding assistant
533 // prose. Matches the transcript-side framing in renderMessage.
534 lines = append(lines, toolBlockRule(v.Theme, width))
535 lines = append(lines, head)
536 if tc.Result != "" {
537 color := v.Theme.ToolOut
538 if tc.Error {
539 color = v.Theme.Error
540 }
541 body := toolResultBlock(v.Theme, tc.Result, width, color)
542 lines = append(lines, v.collapseToolBody(body, false)...)
543 }
544 lines = append(lines, toolBlockRule(v.Theme, width))
545 return lines
546}
547
548// renderLiveToolBody renders the in-flight preview of a streaming
549// tool call. Supported tools:
550//
551// - write: shows the partial `content` field, syntax-highlighted
552// by the target path's language.
553// - edit: shows the partial `newText` of the edit currently being
554// streamed, prefixed with a "editing foo.ts (edit 2)" header so
555// the user can see which of a multi-edit batch is in progress.
556//
557// Anything else returns nil and only the tool-call header shows.
558func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
559 switch tc.Name {
560 case "write", "Write":
561 partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
562 if !ok || partial == "" {
563 return nil
564 }
565 return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
566 case "edit", "Edit":
567 partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
568 if !ok || partial == "" {
569 return nil
570 }
571 // Header line hints which edit is streaming and, when more
572 // than one has landed, how many the model is doing.
573 hint := fmt.Sprintf(" edit %d (streaming)", idx)
574 body := []string{v.Theme.FG256(v.Theme.Muted, hint), ""}
575 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
576 return v.wrapLiveBody(body, width)
577 }
578 return nil
579}
580
581// wrapLiveBody wraps a list of content lines with the standard
582// wrapLiveBody wraps a list of content lines with the standard
583// tool-result rules (top + bottom), collapsing to the preview
584// height if the body is tall. Shared between write and edit
585// streaming.
586func (v *View) wrapLiveBody(body []string, width int) []string {
587 body = v.collapseToolBody(body, false)
588 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
589 out := make([]string, 0, len(body)+2)
590 out = append(out, rule)
591 out = append(out, body...)
592 out = append(out, rule)
593 return out
594}
595
596// toolResultBlock wraps text in thin horizontal rules (top + bottom),
597// indenting the body with four spaces. The rules span the content column.
598// toolBlockRule renders the muted horizontal separator drawn
599// above and below a tool call block. Spans the full content
600// width so it reads as a real section break in the chat
601// regardless of terminal size.
602func toolBlockRule(th Theme, width int) string {
603 w := width
604 if w < 8 {
605 w = 8
606 }
607 return th.FG256(th.Muted, strings.Repeat("─", w))
608}
609
610// renderToolResultContent renders the body of a tool result block.
611// Text blocks get the usual rules-wrapped treatment; text that looks
612// like a unified diff gets +/- coloring. Image blocks are rendered
613// inline when the terminal supports a protocol, else as a text
614// placeholder with dimensions.
615func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
616 var body []string
617 hasImage := false
618 for _, b := range blocks {
619 switch bb := b.(type) {
620 case provider.TextBlock:
621 body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
622 case provider.ImageBlock:
623 hasImage = true
624 body = append(body, v.renderImageBlock(bb, width)...)
625 }
626 }
627 return v.collapseToolBody(body, hasImage)
628}
629
630// collapseToolBody trims lines to the configured preview size when the
631// view is not in ExpandAll mode, appending a muted "... ctrl+o to
632// expand" footer. Image blocks never collapse — they're short in text
633// rows but represent real content the user wants to see.
634func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
635 if v.ExpandAll || hasImage {
636 return lines
637 }
638 if len(lines) <= ToolCollapseLines {
639 return lines
640 }
641 kept := lines[:ToolCollapsePreview]
642 hidden := len(lines) - ToolCollapsePreview
643 total := len(lines)
644 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
645 footer = v.Theme.FG256(v.Theme.Muted, footer)
646 return append(append([]string(nil), kept...), footer)
647}
648
649// renderToolText renders a text block inside a tool result. If the
650// text contains a unified-diff section (lines starting with "--- " /
651// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
652// colors matching git diff conventions.
653func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
654 // Legacy path: transcripts saved before we dropped line numbers
655 // from the read tool still carry " 1\t…" prefixes. Detect and
656 // strip them, then fall through to the highlighter.
657 if looksLikeNumberedFile(text) {
658 return v.renderNumberedFile(text, sourcePath)
659 }
660 // If the result embeds a unified diff (the edit tool's output
661 // starts with a short "applied N edit(s)" line and then a
662 // standard --- / +++ / +/- patch), render the patch with
663 // add/remove coloring. This takes priority over the file-like
664 // detector below because a diff technically has many lines of
665 // "file content" but what the user cares about is what changed,
666 // not a dump of the post-edit file.
667 if looksLikeUnifiedDiff(text) {
668 return v.renderUnifiedDiff(text, width, sourcePath)
669 }
670
671 // Current path: text came from `read` as raw file bytes. When a
672 // source path is known (the call had a `path` arg), render with
673 // a synthetic line-number gutter starting at startLine so the
674 // on-screen view still looks like cat -n. Doesn't apply to non-
675 // file tool outputs (bash stdout, display notes, etc.).
676 if sourcePath != "" && looksLikeFileContent(text) {
677 return v.renderRawFile(text, sourcePath, startLine)
678 }
679
680 // No truncation — the full tool output is rendered into chat and
681 // becomes part of the scrollback you can page back through.
682 lines := strings.Split(text, "\n")
683
684 // Bash-result styling: when the first row looks like a shell
685 // prompt line ("$ ...") emitted by the bash tool, style the
686 // prompt line in accent and the trailing "[exit N] Took X.Ys"
687 // line in muted type so the command + timing read at a glance.
688 // Everything between is left on the default tool-output color.
689 if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
690 return v.renderBashResult(lines, width, defaultColor)
691 }
692
693 inDiff := false
694 oldLine, newLine := 1, 1
695 var out []string
696 for _, l := range lines {
697 // Detect diff header: "--- name" followed somewhere by "+++ name".
698 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
699 inDiff = true
700 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
701 continue
702 }
703 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
704 // that skip around in the file still get correct numbering.
705 if inDiff && strings.HasPrefix(l, "@@") {
706 if o, n, ok := parseHunkHeader(l); ok {
707 oldLine, newLine = o, n
708 }
709 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
710 continue
711 }
712 if inDiff && len(l) > 0 {
713 switch l[0] {
714 case '+':
715 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
716 newLine++
717 continue
718 case '-':
719 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
720 oldLine++
721 continue
722 case ' ':
723 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
724 oldLine++
725 newLine++
726 continue
727 }
728 }
729 // Regular line.
730 for _, w := range wrapLine(l, width-4, " ") {
731 out = append(out, " "+v.Theme.FG256(defaultColor, w))
732 }
733 }
734 return out
735}
736
737// parseHunkHeader extracts the starting old/new line from a unified
738// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
739// header is malformed or missing numbers.
740func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
741 // Skip "@@ "
742 rest := strings.TrimPrefix(l, "@@")
743 rest = strings.TrimSpace(rest)
744 if !strings.HasPrefix(rest, "-") {
745 return 0, 0, false
746 }
747 rest = rest[1:]
748 space := strings.IndexByte(rest, ' ')
749 if space < 0 {
750 return 0, 0, false
751 }
752 oldPart := rest[:space]
753 rest = strings.TrimSpace(rest[space+1:])
754 if !strings.HasPrefix(rest, "+") {
755 return 0, 0, false
756 }
757 rest = rest[1:]
758 if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
759 rest = rest[:sp]
760 }
761 parseStart := func(s string) (int, bool) {
762 if c := strings.IndexByte(s, ','); c >= 0 {
763 s = s[:c]
764 }
765 n, err := strconv.Atoi(s)
766 if err != nil || n < 1 {
767 return 0, false
768 }
769 return n, true
770 }
771 o, ok1 := parseStart(oldPart)
772 n, ok2 := parseStart(rest)
773 if !ok1 || !ok2 {
774 return 0, 0, false
775 }
776 return o, n, true
777}
778
779// renderDiffRow renders one unified-diff line with a read-style gutter
780// (6-cell right-aligned line number, muted) followed by the +/-/space
781// marker and the code. Code is syntax-highlighted if sourcePath hints
782// at a known language; falls back to the plain diff color otherwise.
783func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
784 if len(line) == 0 {
785 return ""
786 }
787 code := expandTabs(line[1:]) // strip the leading marker; expand tabs
788
789 // Syntax-highlight the code half when we know the language. Use
790 // the same HighlightCode pipeline as renderNumberedFile so the
791 // palette matches.
792 lang := LanguageFromPath(sourcePath)
793 var codeRendered string
794 if lang != "" {
795 if h := HighlightCode(code, lang); len(h) == 1 {
796 codeRendered = h[0]
797 }
798 }
799 if codeRendered == "" {
800 if mark == ' ' {
801 codeRendered = v.Theme.FG256(v.Theme.Muted, code)
802 } else {
803 codeRendered = v.Theme.FG256(color, code)
804 }
805 }
806
807 // Gutter shape: sign + number share a color so they read as one
808 // visual token ("+123") instead of a neutral line number next to
809 // a stray marker. Unchanged context lines get a muted gutter and
810 // a leading space so column alignment stays consistent with +/-
811 // rows.
812 var gutterText string
813 switch mark {
814 case '+':
815 gutterText = fmt.Sprintf("+%3d ", lineNo)
816 case '-':
817 gutterText = fmt.Sprintf("-%3d ", lineNo)
818 default:
819 gutterText = fmt.Sprintf(" %3d ", lineNo)
820 }
821 var gutter string
822 if mark == ' ' {
823 gutter = v.Theme.FG256(v.Theme.Muted, gutterText)
824 } else {
825 gutter = v.Theme.FG256(color, gutterText)
826 }
827 row := " " + gutter + codeRendered
828
829 // Cheap width clamp: truncate visible text if the raw code is too
830 // long. We work on the pre-ANSI code string because measuring ansi
831 // output is unreliable.
832 maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
833 if maxCode > 0 && len(code) > maxCode {
834 trunc := code[:maxCode-1] + "…"
835 if lang != "" {
836 if h := HighlightCode(trunc, lang); len(h) == 1 {
837 codeRendered = h[0]
838 } else if mark == ' ' {
839 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
840 } else {
841 codeRendered = v.Theme.FG256(color, trunc)
842 }
843 } else if mark == ' ' {
844 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
845 } else {
846 codeRendered = v.Theme.FG256(color, trunc)
847 }
848 row = " " + gutter + codeRendered
849 }
850 return row
851}
852
853// renderImageBlock returns the lines for one image, inline if possible.
854//
855// Inline image escapes paint into multiple terminal rows but the zot
856// renderer treats each slice entry as a single row. To prevent chat
857// content from being drawn on top of the image, we pad with blank rows
858// so the image's real footprint is reflected in the frame height.
859func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
860 w, h := ImageDimensions(b.Data)
861 kb := len(b.Data) / 1024
862 // Four-space indent matches every other tool-output row
863 // (renderRawFile, renderBashResult, diff rows, "... N more
864 // lines" footers). Keeps the image's metadata line vertically
865 // aligned with the file content above / below it.
866 info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb)
867
868 if v.ImageProto != ImageProtocolNone {
869 // Clamp rendered width so the image never overflows the chat
870 // column. Subtract a 4-cell indent for the tool result block,
871 // cap at 60 cells, floor at 10.
872 cells := width - 4
873 if cells > 60 {
874 cells = 60
875 }
876 if cells < 10 {
877 cells = 10
878 }
879 const maxRows = 20
880 if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
881 rows := RowsForInlineImage(b.Data, cells, maxRows)
882 if rows < 1 {
883 rows = 1
884 }
885 out := make([]string, 0, rows+3)
886 out = append(out, " "+seq)
887 // Reserve blank rows matching the image's on-screen
888 // height, plus one extra. RowsForInlineImage estimates
889 // the height from a fixed cell aspect ratio; real
890 // terminals differ enough that the image often spills
891 // one row past the estimate and repaints over the info
892 // line below. The extra row absorbs that spillover.
893 for i := 1; i < rows+1; i++ {
894 out = append(out, "")
895 }
896 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
897 // Trailing blank so the closing rule / next block has
898 // some breathing room under the info line instead of
899 // sitting flush against it.
900 out = append(out, "")
901 return out
902 }
903 }
904 // Text fallback: same trailing blank for symmetry with the
905 // inline path, so a text-only image placeholder also gets
906 // a little space below before the next content.
907 return []string{v.Theme.FG256(v.Theme.Muted, info), ""}
908}
909
910// looksLikeNumberedFile returns true when text matches the `read`
911// tool's " N\tcontent" format for most of its lines.
912func looksLikeNumberedFile(text string) bool {
913 lines := strings.Split(text, "\n")
914 if len(lines) == 0 {
915 return false
916 }
917 hits := 0
918 scanned := 0
919 for _, l := range lines {
920 if l == "" {
921 continue
922 }
923 scanned++
924 if scanned > 20 {
925 break
926 }
927 if numberedLineRE.MatchString(l) {
928 hits++
929 }
930 }
931 if scanned == 0 {
932 return false
933 }
934 return hits*2 >= scanned // majority of non-empty lines match
935}
936
937var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
938
939// renderNumberedFile strips line numbers, highlights the code, and
940// re-attaches the line numbers in muted color.
941func (v *View) renderNumberedFile(text, sourcePath string) []string {
942 lines := strings.Split(text, "\n")
943 gutters := make([]string, 0, len(lines))
944 codes := make([]string, 0, len(lines))
945 for _, l := range lines {
946 idx := strings.IndexByte(l, '\t')
947 if idx < 0 || !numberedLineRE.MatchString(l) {
948 // Non-code footer (e.g. "[truncated at 2000 lines]").
949 gutters = append(gutters, "")
950 codes = append(codes, l)
951 continue
952 }
953 gutter := l[:idx] + " " // replace tab with single space
954 code := expandTabs(l[idx+1:])
955 gutters = append(gutters, gutter)
956 codes = append(codes, code)
957 }
958 lang := LanguageFromPath(sourcePath)
959 var highlighted []string
960 if lang != "" {
961 highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
962 // Chroma sometimes collapses the trailing empty line. Pad to
963 // align with the gutter slice so per-line zipping works.
964 for len(highlighted) < len(codes) {
965 highlighted = append(highlighted, "")
966 }
967 if len(highlighted) > len(codes) {
968 highlighted = highlighted[:len(codes)]
969 }
970 } else {
971 // No lexer for this file type — render in the ToolOut color so
972 // code is visually distinct from the muted gutter.
973 highlighted = make([]string, len(codes))
974 for i, c := range codes {
975 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
976 }
977 }
978 out := make([]string, 0, len(codes))
979 for i, code := range highlighted {
980 g := gutters[i]
981 if g == "" {
982 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
983 continue
984 }
985 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
986 }
987 return out
988}
989
990// looksLikeFileContent is a cheap guard to distinguish a read-tool
991// result from bash stdout or a status message. File content usually
992// contains characters that status messages don't (code punctuation,
993// longer lines, multiple lines) and rarely starts with the " >"-
994// or "error:"-style prefixes tools emit. False positives are OK,
995// the worst case is a line-number gutter on something that isn't
996// really code.
997// renderBashResult styles a bash tool result: the "$ command" first
998// line in the accent color, the trailing "[exit N] Took X.Ys" line
999// in muted type, everything else on the default tool-output color.
1000// Called from renderToolText when the first line starts with "$ ".
1001func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
1002 // Identify the footer line (exit + timing). The bash tool writes
1003 // it as the last non-empty line of the result.
1004 footerIdx := -1
1005 for i := len(lines) - 1; i >= 0; i-- {
1006 trimmed := strings.TrimSpace(lines[i])
1007 if trimmed == "" {
1008 continue
1009 }
1010 if strings.HasPrefix(trimmed, "[exit ") {
1011 footerIdx = i
1012 }
1013 break
1014 }
1015
1016 var out []string
1017 for i, l := range lines {
1018 switch {
1019 case i == 0 && strings.HasPrefix(l, "$ "):
1020 // Style the $ and the command text in accent. No
1021 // further processing / wrapping so the shell-style
1022 // prompt reads at a glance.
1023 for _, w := range wrapLine(l, width-4, " ") {
1024 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
1025 }
1026 case i == footerIdx:
1027 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
1028 default:
1029 for _, w := range wrapLine(l, width-4, " ") {
1030 out = append(out, " "+v.Theme.FG256(defaultColor, w))
1031 }
1032 }
1033 }
1034 return out
1035}
1036
1037// looksLikeUnifiedDiff reports whether text is a context diff as
1038// emitted by the edit tool: rows start with '+', '-', ' ', or
1039// literal "..." (context-break marker). The presence of at least
1040// one '+' or '-' row distinguishes a real diff from an ordinary
1041// file whose lines happen to begin with a space.
1042func looksLikeUnifiedDiff(text string) bool {
1043 lines := strings.Split(text, "\n")
1044 if len(lines) < 2 {
1045 return false
1046 }
1047 sawChange := false
1048 for _, l := range lines {
1049 if l == "" {
1050 continue
1051 }
1052 if l == "..." {
1053 continue
1054 }
1055 switch l[0] {
1056 case '+', '-':
1057 sawChange = true
1058 case ' ':
1059 // context, ok
1060 default:
1061 return false
1062 }
1063 }
1064 return sawChange
1065}
1066
1067// renderUnifiedDiff renders the edit tool's context diff. Each
1068// kept row shows a line-number gutter plus a marker column: '+'
1069// for additions (colored like add), '-' for deletions (colored
1070// like remove), and unmarked context in muted type. A literal
1071// "..." line between hunks renders as an ellipsis in muted type,
1072// indicating skipped unchanged rows. The old and new line
1073// counters advance so each row carries its actual position in
1074// the pre- or post-edit file. Heuristic: when we hit a "...", we
1075// can't know where the next hunk starts, so we don't reset the
1076// counters — they stay approximate in the rare multi-hunk case.
1077func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
1078 lines := strings.Split(text, "\n")
1079 if n := len(lines); n > 0 && lines[n-1] == "" {
1080 lines = lines[:n-1]
1081 }
1082 oldLine, newLine := 1, 1
1083 var out []string
1084 for _, l := range lines {
1085 if l == "" {
1086 out = append(out, "")
1087 continue
1088 }
1089 if l == "..." {
1090 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))
1091 continue
1092 }
1093 switch l[0] {
1094 case '+':
1095 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
1096 newLine++
1097 case '-':
1098 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
1099 oldLine++
1100 case ' ':
1101 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
1102 oldLine++
1103 newLine++
1104 default:
1105 for _, w := range wrapLine(l, width-4, " ") {
1106 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1107 }
1108 }
1109 }
1110 return out
1111}
1112
1113func looksLikeFileContent(text string) bool {
1114 if strings.TrimSpace(text) == "" {
1115 return false
1116 }
1117 lines := strings.Split(text, "\n")
1118 return len(lines) >= 2
1119}
1120
1121// renderRawFile renders file content received without embedded line
1122// numbers (the current read-tool output). Draws a muted gutter like
1123// "%6d \t" starting at startLine, highlights the code using the
1124// source path's language, and returns the formatted lines.
1125func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
1126 lines := strings.Split(text, "\n")
1127 // Drop the trailing empty line that Split produces when text ends
1128 // in "\n" so the gutter doesn't show a phantom last number.
1129 if n := len(lines); n > 0 && lines[n-1] == "" {
1130 lines = lines[:n-1]
1131 }
1132 // Split code from trailing footer lines ("... [truncated ...]")
1133 // so we don't number the footer.
1134 codeEnd := len(lines)
1135 for i := len(lines) - 1; i >= 0; i-- {
1136 if strings.HasPrefix(lines[i], "...") {
1137 codeEnd = i
1138 continue
1139 }
1140 break
1141 }
1142 code := lines[:codeEnd]
1143 footer := lines[codeEnd:]
1144
1145 // Expand tabs to 4 spaces so Go / Makefile code renders
1146 // at a consistent width.
1147 for i, c := range code {
1148 code[i] = expandTabs(c)
1149 }
1150
1151 lang := LanguageFromPath(sourcePath)
1152 var highlighted []string
1153 if lang != "" {
1154 highlighted = HighlightCode(strings.Join(code, "\n"), lang)
1155 for len(highlighted) < len(code) {
1156 highlighted = append(highlighted, "")
1157 }
1158 if len(highlighted) > len(code) {
1159 highlighted = highlighted[:len(code)]
1160 }
1161 } else {
1162 highlighted = make([]string, len(code))
1163 for i, c := range code {
1164 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
1165 }
1166 }
1167
1168 out := make([]string, 0, len(lines))
1169 for i, c := range highlighted {
1170 gutter := fmt.Sprintf("%4d ", startLine+i)
1171 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
1172 }
1173 for _, f := range footer {
1174 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
1175 }
1176 return out
1177}
1178
1179// toolResultBlock renders the live tool-call result body (shown
1180// while the turn is still in flight). The rules that used to
1181// bracket this block have been dropped so the live path looks
1182// identical to the transcript rendering that replaces it when
1183// the turn ends.
1184func toolResultBlock(th Theme, text string, width int, color int) []string {
1185 var out []string
1186 for _, l := range strings.Split(text, "\n") {
1187 for _, w := range wrapLine(l, width-4, " ") {
1188 out = append(out, " "+th.FG256(color, w))
1189 }
1190 }
1191 return out
1192}
1193
1194// ShortArgs renders a tool call's arguments into a one-line
1195// suffix for the "tool name <args>" header. tool is the tool
1196// name so we can add shape-specific decorations: for read we
1197// append the requested line range (e.g. "path:1-200") pulled
1198// from the offset/limit args, which is useful context at a
1199// glance without expanding the result body. Other tools keep
1200// the legacy "path or command, truncated at 60 cells" shape.
1201//
1202// Exported because the interactive mode pre-populates the
1203// ToolCallView.Args field with this value as soon as the tool
1204// call is announced, so the live overlay's header matches what
1205// the finalised transcript will later render.
1206func ShortArgs(tool string, raw json.RawMessage) string {
1207 var v any
1208 if err := json.Unmarshal(raw, &v); err != nil {
1209 return ""
1210 }
1211 x, ok := v.(map[string]any)
1212 if !ok {
1213 b, _ := json.Marshal(v)
1214 s := string(b)
1215 if len(s) > 60 {
1216 s = s[:57] + "..."
1217 }
1218 return s
1219 }
1220 var primary string
1221 for _, k := range []string{"path", "file_path", "command"} {
1222 if s, ok := x[k].(string); ok {
1223 primary = s
1224 break
1225 }
1226 }
1227 if primary == "" {
1228 b, _ := json.Marshal(v)
1229 s := string(b)
1230 if len(s) > 60 {
1231 s = s[:57] + "..."
1232 }
1233 return s
1234 }
1235
1236 // Tool-specific decoration. Only the read tool gets a range
1237 // suffix for now; other tools just truncate the primary arg.
1238 suffix := ""
1239 switch strings.ToLower(tool) {
1240 case "read":
1241 start := 1
1242 if n, ok := toInt(x["offset"]); ok && n >= 1 {
1243 start = n
1244 }
1245 if lim, ok := toInt(x["limit"]); ok && lim > 0 {
1246 end := start + lim - 1
1247 suffix = fmt.Sprintf(":%d-%d", start, end)
1248 } else if start > 1 {
1249 suffix = fmt.Sprintf(":%d-", start)
1250 }
1251 }
1252
1253 // Truncate the primary arg leaving room for the suffix so the
1254 // range stays visible even on absurdly long paths.
1255 max := 60 - len(suffix)
1256 if max < 10 {
1257 max = 10
1258 }
1259 if len(primary) > max {
1260 primary = primary[:max-3] + "..."
1261 }
1262 return primary + suffix
1263}
1264
1265// toInt coerces a json.Unmarshal'd number (float64) or a string
1266// containing a number into an int. Returns ok=false if the value
1267// is neither. Used by shortArgs to survive model quirks where
1268// numeric args come back as strings.
1269func toInt(v any) (int, bool) {
1270 switch n := v.(type) {
1271 case float64:
1272 return int(n), true
1273 case int:
1274 return n, true
1275 case string:
1276 i, err := strconv.Atoi(strings.TrimSpace(n))
1277 if err != nil {
1278 return 0, false
1279 }
1280 return i, true
1281 }
1282 return 0, false
1283}
1284
1285func collectText(blocks []provider.Content) string {
1286 var sb strings.Builder
1287 for _, b := range blocks {
1288 if tb, ok := b.(provider.TextBlock); ok {
1289 if sb.Len() > 0 {
1290 sb.WriteString("\n")
1291 }
1292 sb.WriteString(tb.Text)
1293 }
1294 }
1295 return sb.String()
1296}
1297
1298func truncateLines(s string, n int) string {
1299 lines := strings.Split(s, "\n")
1300 if len(lines) <= n {
1301 return s
1302 }
1303 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1304}
1305
1306// renderCompactionBlock renders a compaction summary as a distinct
1307// visual block in the chat. When collapsed it shows a one-line label
1308// with the pre-compaction token count; when expanded (ctrl+o) it
1309// shows the full summary text.
1310func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
1311 th := v.Theme
1312 const indent = " "
1313
1314 tokens := m.Meta["tokens_before"]
1315 if tokens == "" {
1316 tokens = "?"
1317 }
1318
1319 if v.ExpandAll {
1320 var lines []string
1321 header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
1322 lines = append(lines, indent+header)
1323 lines = append(lines, "")
1324 for _, c := range m.Content {
1325 if tb, ok := c.(provider.TextBlock); ok {
1326 text := tb.Text
1327 if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
1328 text = text[idx+2:]
1329 }
1330 md := RenderMarkdown(text, th, width-4)
1331 for _, l := range strings.Split(md, "\n") {
1332 lines = append(lines, indent+l)
1333 }
1334 }
1335 }
1336 return lines
1337 }
1338
1339 // Collapsed: single line, no banner.
1340 line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
1341 return []string{indent + line}
1342}
1343
1344// StatusBarParams groups the many bits of state the status bar needs.
1345// Grew from a flat argument list once we settled on the layout.
1346type StatusBarParams struct {
1347 Theme Theme
1348 Provider string
1349 Model string
1350 Busy bool
1351 BusyPrefix string // spinner + funny line when busy
1352 CWD string
1353 Locked bool // sandbox on?
1354
1355 // Cumulative session usage and cost.
1356 Usage provider.Usage
1357 // Subscription is true when the credential is an OAuth token (claude
1358 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
1359 // compute a cost for visibility and append "(sub)" so the user
1360 // knows no real money moved.
1361 Subscription bool
1362
1363 // Last turn's input+cache tokens (approximates current live context).
1364 ContextUsed int
1365 ContextMax int // model's context window; 0 disables the percentage
1366
1367 // AutoCompacting is true when the agent is currently running a
1368 // model-triggered condense pass. Surfaces as "(auto)" after the
1369 // context percentage so it's clear where the spinner is coming from.
1370 AutoCompacting bool
1371
1372 // Telegram true when the telegram bridge is connected. Adds a
1373 // small "- tg -" tag to the cwd line so the user can tell at a
1374 // glance that dms are being mirrored into this session.
1375 Telegram bool
1376
1377 Cols int // terminal width; drives right-alignment of cwd
1378}
1379
1380// StatusBar builds the status shown above the editor. Always returns
1381// two lines when a cwd is provided: the stats on the first line, the
1382// cwd on its own line below, indented to match the stats column. This
1383// keeps the status bar stable across terminal resizes (the cwd never
1384// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
1385// makes a long cwd safe at any width.
1386//
1387// Layout:
1388//
1389// <busyPrefix> (provider) model stats <- line 1
1390// cwd <- line 2 (2-space indent)
1391//
1392// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
1393// The slash-command popup and the queued/sliding-in chips already
1394// cover the discoverability of those keybindings.
1395func StatusBar(p StatusBarParams) []string {
1396 th := p.Theme
1397
1398 // Token stats: only include each segment when non-zero. Keeps
1399 // the bar compact on brand-new sessions.
1400 var stats []string
1401 if p.Usage.InputTokens > 0 {
1402 stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
1403 }
1404 if p.Usage.OutputTokens > 0 {
1405 stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
1406 }
1407 if p.Usage.CacheReadTokens > 0 {
1408 stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
1409 }
1410 if p.Usage.CacheWriteTokens > 0 {
1411 stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
1412 }
1413
1414 // Cost: always show the dollar value computed from token counts,
1415 // even on subscription. Lets you see what the equivalent api cost
1416 // would be (handy for gauging subscription value). Append "(sub)"
1417 // only as a hint that no real money moved.
1418 var costStr string
1419 if p.Usage.CostUSD > 0 || p.Subscription {
1420 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
1421 if p.Subscription {
1422 costStr += " (sub)"
1423 }
1424 }
1425 if costStr != "" {
1426 stats = append(stats, costStr)
1427 }
1428
1429 // Context %. Color-coded: yellow >70, red >90.
1430 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
1431 if ctx != "" {
1432 if p.AutoCompacting {
1433 ctx += " (auto)"
1434 }
1435 stats = append(stats, th.FG256(ctxColor, ctx))
1436 }
1437
1438 // Layout uses exactly 2 spaces of horizontal padding everywhere:
1439 // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
1440 // matches the editor prompt's left inset so the bar lines up
1441 // vertically with the conversation column.
1442 const pad = " " // 2 spaces
1443
1444 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
1445 middle := strings.Join(stats, " ")
1446
1447 var leftBuilder strings.Builder
1448 if p.BusyPrefix != "" {
1449 // Don't re-color the payload itself — the caller already set
1450 // fg colors on the spinner glyph, message, and elapsed
1451 // segments. Wrapping the whole thing here would override
1452 // those choices. The pad itself needs no color (it's spaces).
1453 leftBuilder.WriteString(pad + p.BusyPrefix)
1454 // Exactly one pad (2 spaces) between the busy segment and
1455 // the provider/model block. The leading pad above covers
1456 // the left indent.
1457 leftBuilder.WriteString(pad)
1458 } else {
1459 // Idle path: a single pad of left inset so the line
1460 // aligns with the conversation column on its left edge
1461 // (" you" / " zot" message markers). Without the busy
1462 // prefix there's no trailing separator to double-pad.
1463 leftBuilder.WriteString(pad)
1464 }
1465 leftBuilder.WriteString(th.FG256(th.Muted, left))
1466 if middle != "" {
1467 leftBuilder.WriteString(pad)
1468 // `middle` already has colorized context segments; wrap the rest in muted.
1469 leftBuilder.WriteString(th.FG256(th.Muted, middle))
1470 }
1471
1472 cwd := shortenHome(p.CWD)
1473 tags := ""
1474 if p.Locked {
1475 tags += "- jailed "
1476 }
1477 if p.Telegram {
1478 tags += "- tg "
1479 }
1480 if tags != "" && cwd != "" {
1481 cwd = tags + "- " + cwd
1482 }
1483
1484 primary := leftBuilder.String()
1485
1486 // On narrow terminals the single line wraps badly. If the visible
1487 // width exceeds cols and we have a busy prefix, split: keep the
1488 // busy prefix on line 1, put model+stats on line 2.
1489 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
1490 busyLine := pad + p.BusyPrefix
1491 var infoBuilder strings.Builder
1492 infoBuilder.WriteString(pad)
1493 infoBuilder.WriteString(th.FG256(th.Muted, left))
1494 if middle != "" {
1495 infoBuilder.WriteString(pad)
1496 infoBuilder.WriteString(th.FG256(th.Muted, middle))
1497 }
1498 lines := []string{busyLine, infoBuilder.String()}
1499 if cwd != "" {
1500 lines = append(lines, pad+th.FG256(th.Muted, cwd))
1501 }
1502 return lines
1503 }
1504
1505 if cwd == "" {
1506 return []string{primary}
1507 }
1508
1509 // Second line: indent with the same 2-space pad so the cwd lines
1510 // up under the "(provider)" column on line 1.
1511 cwdRendered := pad + th.FG256(th.Muted, cwd)
1512 return []string{primary, cwdRendered}
1513}
1514
1515// contextUsage renders the "N%/ctxMax" fragment, returning the
1516// rendered string plus the colour to wrap it in.
1517func contextUsage(th Theme, used, max int) (string, int) {
1518 if max <= 0 {
1519 if used <= 0 {
1520 return "", th.Muted
1521 }
1522 return formatTokens(used), th.Muted
1523 }
1524 pct := float64(used) / float64(max) * 100
1525 text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
1526 switch {
1527 case pct > 90:
1528 return text, th.Error
1529 case pct > 70:
1530 return text, th.Warning
1531 }
1532 return text, th.Muted
1533}
1534
1535// formatTokens footer formatter:
1536//
1537// < 1000 -> "42"
1538// < 10_000 -> "2.7k"
1539// < 1_000_000 -> "35k"
1540// < 10M -> "1.1M"
1541// else -> "12M"
1542func formatTokens(n int) string {
1543 switch {
1544 case n < 0:
1545 return "0"
1546 case n < 1000:
1547 return fmt.Sprintf("%d", n)
1548 case n < 10000:
1549 return fmt.Sprintf("%.1fk", float64(n)/1000)
1550 case n < 1_000_000:
1551 return fmt.Sprintf("%dk", (n+500)/1000)
1552 case n < 10_000_000:
1553 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
1554 default:
1555 return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
1556 }
1557}
1558
1559// shortenHome replaces the user's $HOME prefix with "~" for readability.
1560func shortenHome(p string) string {
1561 if p == "" {
1562 return ""
1563 }
1564 home := osUserHome()
1565 if home == "" {
1566 return p
1567 }
1568 if p == home {
1569 return "~"
1570 }
1571 if strings.HasPrefix(p, home+"/") {
1572 return "~" + p[len(home):]
1573 }
1574 return p
1575}
1576
1577// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
1578var osUserHome = func() string {
1579 if h, err := osUserHomeDir(); err == nil {
1580 return h
1581 }
1582 return ""
1583}