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 // Match the transcript-side framing: tool headers sit indented four
515 // cells under the assistant body column. The live overlay used to skip
516 // the indent which made streaming tools jump back to column 0.
517 head := " " + v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg)
518
519 // Live streaming body: pulled out of the partial JSON buffer for
520 // tools whose interesting content is a string field (currently
521 // write's `content` and edit's `new_text` chunks). The body is
522 // already framed by wrapLiveBody with top+bottom rules, so we
523 // don't add the extra toolBlockRule around it — that would
524 // produce four rules per streaming block, with a visible doubled
525 // line at the bottom.
526 if tc.Streaming && tc.Result == "" {
527 lines = append(lines, head)
528 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
529 lines = append(lines, body...)
530 }
531 return lines
532 }
533
534 // Finished tool call: frame the whole block with opening +
535 // closing rules so it stands apart from surrounding assistant
536 // prose. Matches the transcript-side framing in renderMessage.
537 lines = append(lines, toolBlockRule(v.Theme, width))
538 lines = append(lines, head)
539 if tc.Result != "" {
540 color := v.Theme.ToolOut
541 if tc.Error {
542 color = v.Theme.Error
543 }
544 body := toolResultBlock(v.Theme, tc.Result, width, color)
545 lines = append(lines, v.collapseToolBody(body, false)...)
546 }
547 lines = append(lines, toolBlockRule(v.Theme, width))
548 return lines
549}
550
551// renderLiveToolBody renders the in-flight preview of a streaming
552// tool call. Supported tools:
553//
554// - write: shows the partial `content` field, syntax-highlighted
555// by the target path's language.
556// - edit: shows the partial `newText` of the edit currently being
557// streamed, prefixed with a "editing foo.ts (edit 2)" header so
558// the user can see which of a multi-edit batch is in progress.
559//
560// Anything else returns nil and only the tool-call header shows.
561func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
562 switch tc.Name {
563 case "write", "Write":
564 partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
565 if !ok || partial == "" {
566 return nil
567 }
568 return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
569 case "edit", "Edit":
570 partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
571 if !ok || partial == "" {
572 return nil
573 }
574 // Header line hints which edit is streaming and, when more
575 // than one has landed, how many the model is doing.
576 hint := fmt.Sprintf("edit %d (streaming)", idx)
577 body := []string{" " + v.Theme.FG256(v.Theme.Muted, hint), ""}
578 body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
579 return v.wrapLiveBody(body, width)
580 }
581 return nil
582}
583
584// wrapLiveBody wraps a list of content lines with the standard
585// wrapLiveBody wraps a list of content lines with the standard
586// tool-result rules (top + bottom), collapsing to the preview
587// height if the body is tall. Shared between write and edit
588// streaming.
589func (v *View) wrapLiveBody(body []string, width int) []string {
590 body = v.collapseToolBody(body, false)
591 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
592 out := make([]string, 0, len(body)+2)
593 out = append(out, rule)
594 out = append(out, body...)
595 out = append(out, rule)
596 return out
597}
598
599// toolResultBlock wraps text in thin horizontal rules (top + bottom),
600// indenting the body with four spaces. The rules span the content column.
601// toolBlockRule renders the muted horizontal separator drawn
602// above and below a tool call block. Spans the full content
603// width so it reads as a real section break in the chat
604// regardless of terminal size.
605func toolBlockRule(th Theme, width int) string {
606 w := width
607 if w < 8 {
608 w = 8
609 }
610 return th.FG256(th.Muted, strings.Repeat("─", w))
611}
612
613// renderToolResultContent renders the body of a tool result block.
614// Text blocks get the usual rules-wrapped treatment; text that looks
615// like a unified diff gets +/- coloring. Image blocks are rendered
616// inline when the terminal supports a protocol, else as a text
617// placeholder with dimensions.
618func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
619 var body []string
620 hasImage := false
621 for _, b := range blocks {
622 switch bb := b.(type) {
623 case provider.TextBlock:
624 body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
625 case provider.ImageBlock:
626 hasImage = true
627 body = append(body, v.renderImageBlock(bb, width)...)
628 }
629 }
630 return v.collapseToolBody(body, hasImage)
631}
632
633// collapseToolBody trims lines to the configured preview size when the
634// view is not in ExpandAll mode, appending a muted "... ctrl+o to
635// expand" footer. Image blocks never collapse — they're short in text
636// rows but represent real content the user wants to see.
637func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
638 if v.ExpandAll || hasImage {
639 return lines
640 }
641 if len(lines) <= ToolCollapseLines {
642 return lines
643 }
644 kept := lines[:ToolCollapsePreview]
645 hidden := len(lines) - ToolCollapsePreview
646 total := len(lines)
647 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
648 footer = v.Theme.FG256(v.Theme.Muted, footer)
649 return append(append([]string(nil), kept...), footer)
650}
651
652// renderToolText renders a text block inside a tool result. If the
653// text contains a unified-diff section (lines starting with "--- " /
654// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
655// colors matching git diff conventions.
656func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
657 // Legacy path: transcripts saved before we dropped line numbers
658 // from the read tool still carry " 1\t…" prefixes. Detect and
659 // strip them, then fall through to the highlighter.
660 if looksLikeNumberedFile(text) {
661 return v.renderNumberedFile(text, sourcePath)
662 }
663 // If the result embeds a unified diff (the edit tool's output
664 // starts with a short "applied N edit(s)" line and then a
665 // standard --- / +++ / +/- patch), render the patch with
666 // add/remove coloring. This takes priority over the file-like
667 // detector below because a diff technically has many lines of
668 // "file content" but what the user cares about is what changed,
669 // not a dump of the post-edit file.
670 if looksLikeUnifiedDiff(text) {
671 return v.renderUnifiedDiff(text, width, sourcePath)
672 }
673
674 // Current path: text came from `read` as raw file bytes. When a
675 // source path is known (the call had a `path` arg), render with
676 // a synthetic line-number gutter starting at startLine so the
677 // on-screen view still looks like cat -n. Doesn't apply to non-
678 // file tool outputs (bash stdout, display notes, etc.).
679 if sourcePath != "" && looksLikeFileContent(text) {
680 return v.renderRawFile(text, sourcePath, startLine)
681 }
682
683 // No truncation — the full tool output is rendered into chat and
684 // becomes part of the scrollback you can page back through.
685 lines := strings.Split(text, "\n")
686
687 // Bash-result styling: when the first row looks like a shell
688 // prompt line ("$ ...") emitted by the bash tool, style the
689 // prompt line in accent and the trailing "[exit N] Took X.Ys"
690 // line in muted type so the command + timing read at a glance.
691 // Everything between is left on the default tool-output color.
692 if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
693 return v.renderBashResult(lines, width, defaultColor)
694 }
695
696 inDiff := false
697 oldLine, newLine := 1, 1
698 var out []string
699 for _, l := range lines {
700 // Detect diff header: "--- name" followed somewhere by "+++ name".
701 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
702 inDiff = true
703 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
704 continue
705 }
706 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
707 // that skip around in the file still get correct numbering.
708 if inDiff && strings.HasPrefix(l, "@@") {
709 if o, n, ok := parseHunkHeader(l); ok {
710 oldLine, newLine = o, n
711 }
712 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
713 continue
714 }
715 if inDiff && len(l) > 0 {
716 switch l[0] {
717 case '+':
718 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
719 newLine++
720 continue
721 case '-':
722 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
723 oldLine++
724 continue
725 case ' ':
726 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
727 oldLine++
728 newLine++
729 continue
730 }
731 }
732 // Regular line.
733 for _, w := range wrapLine(l, width-4, " ") {
734 out = append(out, " "+v.Theme.FG256(defaultColor, w))
735 }
736 }
737 return out
738}
739
740// parseHunkHeader extracts the starting old/new line from a unified
741// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
742// header is malformed or missing numbers.
743func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
744 // Skip "@@ "
745 rest := strings.TrimPrefix(l, "@@")
746 rest = strings.TrimSpace(rest)
747 if !strings.HasPrefix(rest, "-") {
748 return 0, 0, false
749 }
750 rest = rest[1:]
751 space := strings.IndexByte(rest, ' ')
752 if space < 0 {
753 return 0, 0, false
754 }
755 oldPart := rest[:space]
756 rest = strings.TrimSpace(rest[space+1:])
757 if !strings.HasPrefix(rest, "+") {
758 return 0, 0, false
759 }
760 rest = rest[1:]
761 if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
762 rest = rest[:sp]
763 }
764 parseStart := func(s string) (int, bool) {
765 if c := strings.IndexByte(s, ','); c >= 0 {
766 s = s[:c]
767 }
768 n, err := strconv.Atoi(s)
769 if err != nil || n < 1 {
770 return 0, false
771 }
772 return n, true
773 }
774 o, ok1 := parseStart(oldPart)
775 n, ok2 := parseStart(rest)
776 if !ok1 || !ok2 {
777 return 0, 0, false
778 }
779 return o, n, true
780}
781
782// renderDiffRow renders one unified-diff line with a read-style gutter
783// (6-cell right-aligned line number, muted) followed by the +/-/space
784// marker and the code. Code is syntax-highlighted if sourcePath hints
785// at a known language; falls back to the plain diff color otherwise.
786func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
787 if len(line) == 0 {
788 return ""
789 }
790 code := expandTabs(line[1:]) // strip the leading marker; expand tabs
791
792 // Syntax-highlight the code half when we know the language. Use
793 // the same HighlightCode pipeline as renderNumberedFile so the
794 // palette matches.
795 lang := LanguageFromPath(sourcePath)
796 var codeRendered string
797 if lang != "" {
798 if h := HighlightCode(code, lang); len(h) == 1 {
799 codeRendered = h[0]
800 }
801 }
802 if codeRendered == "" {
803 if mark == ' ' {
804 codeRendered = v.Theme.FG256(v.Theme.Muted, code)
805 } else {
806 codeRendered = v.Theme.FG256(color, code)
807 }
808 }
809
810 // Gutter shape: sign + number share a color so they read as one
811 // visual token ("+123") instead of a neutral line number next to
812 // a stray marker. Unchanged context lines get a muted gutter and
813 // a leading space so column alignment stays consistent with +/-
814 // rows.
815 var gutterText string
816 switch mark {
817 case '+':
818 gutterText = fmt.Sprintf("+%3d ", lineNo)
819 case '-':
820 gutterText = fmt.Sprintf("-%3d ", lineNo)
821 default:
822 gutterText = fmt.Sprintf(" %3d ", lineNo)
823 }
824 var gutter string
825 if mark == ' ' {
826 gutter = v.Theme.FG256(v.Theme.Muted, gutterText)
827 } else {
828 gutter = v.Theme.FG256(color, gutterText)
829 }
830 row := " " + gutter + codeRendered
831
832 // Cheap width clamp: truncate visible text if the raw code is too
833 // long. We work on the pre-ANSI code string because measuring ansi
834 // output is unreliable.
835 maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
836 if maxCode > 0 && len(code) > maxCode {
837 trunc := code[:maxCode-1] + "…"
838 if lang != "" {
839 if h := HighlightCode(trunc, lang); len(h) == 1 {
840 codeRendered = h[0]
841 } else if mark == ' ' {
842 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
843 } else {
844 codeRendered = v.Theme.FG256(color, trunc)
845 }
846 } else if mark == ' ' {
847 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
848 } else {
849 codeRendered = v.Theme.FG256(color, trunc)
850 }
851 row = " " + gutter + codeRendered
852 }
853 return row
854}
855
856// renderImageBlock returns the lines for one image, inline if possible.
857//
858// Inline image escapes paint into multiple terminal rows but the zot
859// renderer treats each slice entry as a single row. To prevent chat
860// content from being drawn on top of the image, we pad with blank rows
861// so the image's real footprint is reflected in the frame height.
862func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
863 w, h := ImageDimensions(b.Data)
864 kb := len(b.Data) / 1024
865 // Four-space indent matches every other tool-output row
866 // (renderRawFile, renderBashResult, diff rows, "... N more
867 // lines" footers). Keeps the image's metadata line vertically
868 // aligned with the file content above / below it.
869 info := fmt.Sprintf(" image - %s - %dx%d - %d KB", b.MimeType, w, h, kb)
870
871 if v.ImageProto != ImageProtocolNone {
872 // Clamp rendered width so the image never overflows the chat
873 // column. Subtract a 4-cell indent for the tool result block,
874 // cap at 60 cells, floor at 10.
875 cells := width - 4
876 if cells > 60 {
877 cells = 60
878 }
879 if cells < 10 {
880 cells = 10
881 }
882 const maxRows = 20
883 if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
884 rows := RowsForInlineImage(b.Data, cells, maxRows)
885 if rows < 1 {
886 rows = 1
887 }
888 // Reserve the image footprint first, then place the escape
889 // in the top-left of that blank rectangle. Keeping the
890 // metadata above the image made some terminals blend text and
891 // graphics layers during full-screen repaints; metadata below
892 // the reserved rectangle is more stable and easier to read.
893 out := make([]string, 0, rows+3)
894 out = append(out, " "+seq)
895 for i := 1; i < rows; i++ {
896 out = append(out, "")
897 }
898 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
899 return out
900 }
901 }
902 return []string{v.Theme.FG256(v.Theme.Muted, info)}
903}
904
905// looksLikeNumberedFile returns true when text matches the `read`
906// tool's " N\tcontent" format for most of its lines.
907func looksLikeNumberedFile(text string) bool {
908 lines := strings.Split(text, "\n")
909 if len(lines) == 0 {
910 return false
911 }
912 hits := 0
913 scanned := 0
914 for _, l := range lines {
915 if l == "" {
916 continue
917 }
918 scanned++
919 if scanned > 20 {
920 break
921 }
922 if numberedLineRE.MatchString(l) {
923 hits++
924 }
925 }
926 if scanned == 0 {
927 return false
928 }
929 return hits*2 >= scanned // majority of non-empty lines match
930}
931
932var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
933
934// renderNumberedFile strips line numbers, highlights the code, and
935// re-attaches the line numbers in muted color.
936func (v *View) renderNumberedFile(text, sourcePath string) []string {
937 lines := strings.Split(text, "\n")
938 gutters := make([]string, 0, len(lines))
939 codes := make([]string, 0, len(lines))
940 for _, l := range lines {
941 idx := strings.IndexByte(l, '\t')
942 if idx < 0 || !numberedLineRE.MatchString(l) {
943 // Non-code footer (e.g. "[truncated at 2000 lines]").
944 gutters = append(gutters, "")
945 codes = append(codes, l)
946 continue
947 }
948 gutter := l[:idx] + " " // replace tab with single space
949 code := expandTabs(l[idx+1:])
950 gutters = append(gutters, gutter)
951 codes = append(codes, code)
952 }
953 lang := LanguageFromPath(sourcePath)
954 var highlighted []string
955 if lang != "" {
956 highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
957 // Chroma sometimes collapses the trailing empty line. Pad to
958 // align with the gutter slice so per-line zipping works.
959 for len(highlighted) < len(codes) {
960 highlighted = append(highlighted, "")
961 }
962 if len(highlighted) > len(codes) {
963 highlighted = highlighted[:len(codes)]
964 }
965 } else {
966 // No lexer for this file type — render in the ToolOut color so
967 // code is visually distinct from the muted gutter.
968 highlighted = make([]string, len(codes))
969 for i, c := range codes {
970 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
971 }
972 }
973 out := make([]string, 0, len(codes))
974 for i, code := range highlighted {
975 g := gutters[i]
976 if g == "" {
977 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
978 continue
979 }
980 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
981 }
982 return out
983}
984
985// looksLikeFileContent is a cheap guard to distinguish a read-tool
986// result from bash stdout or a status message. File content usually
987// contains characters that status messages don't (code punctuation,
988// longer lines, multiple lines) and rarely starts with the " >"-
989// or "error:"-style prefixes tools emit. False positives are OK,
990// the worst case is a line-number gutter on something that isn't
991// really code.
992// renderBashResult styles a bash tool result: the "$ command" first
993// line in the accent color, the trailing "[exit N] Took X.Ys" line
994// in muted type, everything else on the default tool-output color.
995// Called from renderToolText when the first line starts with "$ ".
996func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
997 // Identify the footer line (exit + timing). The bash tool writes
998 // it as the last non-empty line of the result.
999 footerIdx := -1
1000 for i := len(lines) - 1; i >= 0; i-- {
1001 trimmed := strings.TrimSpace(lines[i])
1002 if trimmed == "" {
1003 continue
1004 }
1005 if strings.HasPrefix(trimmed, "[exit ") {
1006 footerIdx = i
1007 }
1008 break
1009 }
1010
1011 var out []string
1012 for i, l := range lines {
1013 switch {
1014 case i == 0 && strings.HasPrefix(l, "$ "):
1015 // Style the $ and the command text in accent. No
1016 // further processing / wrapping so the shell-style
1017 // prompt reads at a glance.
1018 for _, w := range wrapLine(l, width-4, " ") {
1019 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
1020 }
1021 case i == footerIdx:
1022 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
1023 default:
1024 for _, w := range wrapLine(l, width-4, " ") {
1025 out = append(out, " "+v.Theme.FG256(defaultColor, w))
1026 }
1027 }
1028 }
1029 return out
1030}
1031
1032// looksLikeUnifiedDiff reports whether text is a context diff as
1033// emitted by the edit tool: rows start with '+', '-', ' ', or
1034// literal "..." (context-break marker). The presence of at least
1035// one '+' or '-' row distinguishes a real diff from an ordinary
1036// file whose lines happen to begin with a space.
1037func looksLikeUnifiedDiff(text string) bool {
1038 lines := strings.Split(text, "\n")
1039 if len(lines) < 2 {
1040 return false
1041 }
1042 sawChange := false
1043 for _, l := range lines {
1044 if l == "" {
1045 continue
1046 }
1047 if l == "..." {
1048 continue
1049 }
1050 switch l[0] {
1051 case '+', '-':
1052 sawChange = true
1053 case ' ':
1054 // context, ok
1055 default:
1056 return false
1057 }
1058 }
1059 return sawChange
1060}
1061
1062// renderUnifiedDiff renders the edit tool's context diff. Each
1063// kept row shows a line-number gutter plus a marker column: '+'
1064// for additions (colored like add), '-' for deletions (colored
1065// like remove), and unmarked context in muted type. A literal
1066// "..." line between hunks renders as an ellipsis in muted type,
1067// indicating skipped unchanged rows. The old and new line
1068// counters advance so each row carries its actual position in
1069// the pre- or post-edit file. Heuristic: when we hit a "...", we
1070// can't know where the next hunk starts, so we don't reset the
1071// counters — they stay approximate in the rare multi-hunk case.
1072func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
1073 lines := strings.Split(text, "\n")
1074 if n := len(lines); n > 0 && lines[n-1] == "" {
1075 lines = lines[:n-1]
1076 }
1077 oldLine, newLine := 1, 1
1078 var out []string
1079 for _, l := range lines {
1080 if l == "" {
1081 out = append(out, "")
1082 continue
1083 }
1084 if l == "..." {
1085 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))
1086 continue
1087 }
1088 switch l[0] {
1089 case '+':
1090 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
1091 newLine++
1092 case '-':
1093 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
1094 oldLine++
1095 case ' ':
1096 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
1097 oldLine++
1098 newLine++
1099 default:
1100 for _, w := range wrapLine(l, width-4, " ") {
1101 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1102 }
1103 }
1104 }
1105 return out
1106}
1107
1108func looksLikeFileContent(text string) bool {
1109 if strings.TrimSpace(text) == "" {
1110 return false
1111 }
1112 lines := strings.Split(text, "\n")
1113 return len(lines) >= 2
1114}
1115
1116// renderRawFile renders file content received without embedded line
1117// numbers (the current read-tool output). Draws a muted gutter like
1118// "%6d \t" starting at startLine, highlights the code using the
1119// source path's language, and returns the formatted lines.
1120func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
1121 lines := strings.Split(text, "\n")
1122 // Drop the trailing empty line that Split produces when text ends
1123 // in "\n" so the gutter doesn't show a phantom last number.
1124 if n := len(lines); n > 0 && lines[n-1] == "" {
1125 lines = lines[:n-1]
1126 }
1127 // Split code from trailing footer lines ("... [truncated ...]")
1128 // so we don't number the footer.
1129 codeEnd := len(lines)
1130 for i := len(lines) - 1; i >= 0; i-- {
1131 if strings.HasPrefix(lines[i], "...") {
1132 codeEnd = i
1133 continue
1134 }
1135 break
1136 }
1137 code := lines[:codeEnd]
1138 footer := lines[codeEnd:]
1139
1140 // Expand tabs to 4 spaces so Go / Makefile code renders
1141 // at a consistent width.
1142 for i, c := range code {
1143 code[i] = expandTabs(c)
1144 }
1145
1146 lang := LanguageFromPath(sourcePath)
1147 var highlighted []string
1148 if lang != "" {
1149 highlighted = HighlightCode(strings.Join(code, "\n"), lang)
1150 for len(highlighted) < len(code) {
1151 highlighted = append(highlighted, "")
1152 }
1153 if len(highlighted) > len(code) {
1154 highlighted = highlighted[:len(code)]
1155 }
1156 } else {
1157 highlighted = make([]string, len(code))
1158 for i, c := range code {
1159 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
1160 }
1161 }
1162
1163 out := make([]string, 0, len(lines))
1164 for i, c := range highlighted {
1165 gutter := fmt.Sprintf("%4d ", startLine+i)
1166 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
1167 }
1168 for _, f := range footer {
1169 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
1170 }
1171 return out
1172}
1173
1174// toolResultBlock renders the live tool-call result body (shown
1175// while the turn is still in flight). The rules that used to
1176// bracket this block have been dropped so the live path looks
1177// identical to the transcript rendering that replaces it when
1178// the turn ends.
1179func toolResultBlock(th Theme, text string, width int, color int) []string {
1180 var out []string
1181 for _, l := range strings.Split(text, "\n") {
1182 for _, w := range wrapLine(l, width-4, " ") {
1183 out = append(out, " "+th.FG256(color, w))
1184 }
1185 }
1186 return out
1187}
1188
1189// ShortArgs renders a tool call's arguments into a one-line
1190// suffix for the "tool name <args>" header. tool is the tool
1191// name so we can add shape-specific decorations: for read we
1192// append the requested line range (e.g. "path:1-200") pulled
1193// from the offset/limit args, which is useful context at a
1194// glance without expanding the result body. Other tools keep
1195// the legacy "path or command, truncated at 60 cells" shape.
1196//
1197// Exported because the interactive mode pre-populates the
1198// ToolCallView.Args field with this value as soon as the tool
1199// call is announced, so the live overlay's header matches what
1200// the finalised transcript will later render.
1201func ShortArgs(tool string, raw json.RawMessage) string {
1202 var v any
1203 if err := json.Unmarshal(raw, &v); err != nil {
1204 return ""
1205 }
1206 x, ok := v.(map[string]any)
1207 if !ok {
1208 b, _ := json.Marshal(v)
1209 s := string(b)
1210 if len(s) > 60 {
1211 s = s[:57] + "..."
1212 }
1213 return s
1214 }
1215 var primary string
1216 for _, k := range []string{"path", "file_path", "command"} {
1217 if s, ok := x[k].(string); ok {
1218 primary = s
1219 break
1220 }
1221 }
1222 if primary == "" {
1223 b, _ := json.Marshal(v)
1224 s := string(b)
1225 if len(s) > 60 {
1226 s = s[:57] + "..."
1227 }
1228 return s
1229 }
1230
1231 // Tool-specific decoration. Only the read tool gets a range
1232 // suffix for now; other tools just truncate the primary arg.
1233 suffix := ""
1234 switch strings.ToLower(tool) {
1235 case "read":
1236 start := 1
1237 if n, ok := toInt(x["offset"]); ok && n >= 1 {
1238 start = n
1239 }
1240 if lim, ok := toInt(x["limit"]); ok && lim > 0 {
1241 end := start + lim - 1
1242 suffix = fmt.Sprintf(":%d-%d", start, end)
1243 } else if start > 1 {
1244 suffix = fmt.Sprintf(":%d-", start)
1245 }
1246 }
1247
1248 // Truncate the primary arg leaving room for the suffix so the
1249 // range stays visible even on absurdly long paths.
1250 max := 60 - len(suffix)
1251 if max < 10 {
1252 max = 10
1253 }
1254 if len(primary) > max {
1255 primary = primary[:max-3] + "..."
1256 }
1257 return primary + suffix
1258}
1259
1260// toInt coerces a json.Unmarshal'd number (float64) or a string
1261// containing a number into an int. Returns ok=false if the value
1262// is neither. Used by shortArgs to survive model quirks where
1263// numeric args come back as strings.
1264func toInt(v any) (int, bool) {
1265 switch n := v.(type) {
1266 case float64:
1267 return int(n), true
1268 case int:
1269 return n, true
1270 case string:
1271 i, err := strconv.Atoi(strings.TrimSpace(n))
1272 if err != nil {
1273 return 0, false
1274 }
1275 return i, true
1276 }
1277 return 0, false
1278}
1279
1280func collectText(blocks []provider.Content) string {
1281 var sb strings.Builder
1282 for _, b := range blocks {
1283 if tb, ok := b.(provider.TextBlock); ok {
1284 if sb.Len() > 0 {
1285 sb.WriteString("\n")
1286 }
1287 sb.WriteString(tb.Text)
1288 }
1289 }
1290 return sb.String()
1291}
1292
1293func truncateLines(s string, n int) string {
1294 lines := strings.Split(s, "\n")
1295 if len(lines) <= n {
1296 return s
1297 }
1298 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1299}
1300
1301// renderCompactionBlock renders a compaction summary as a distinct
1302// visual block in the chat. When collapsed it shows a one-line label
1303// with the pre-compaction token count; when expanded (ctrl+o) it
1304// shows the full summary text.
1305func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
1306 th := v.Theme
1307 const indent = " "
1308
1309 tokens := m.Meta["tokens_before"]
1310 if tokens == "" {
1311 tokens = "?"
1312 }
1313
1314 if v.ExpandAll {
1315 var lines []string
1316 header := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens", tokens))
1317 lines = append(lines, indent+header)
1318 lines = append(lines, "")
1319 for _, c := range m.Content {
1320 if tb, ok := c.(provider.TextBlock); ok {
1321 text := tb.Text
1322 if idx := strings.Index(text, "\n\n"); idx >= 0 && strings.HasPrefix(text, "## Context Summary") {
1323 text = text[idx+2:]
1324 }
1325 md := RenderMarkdown(text, th, width-4)
1326 for _, l := range strings.Split(md, "\n") {
1327 lines = append(lines, indent+l)
1328 }
1329 }
1330 }
1331 return lines
1332 }
1333
1334 // Collapsed: single line, no banner.
1335 line := th.FG256(th.Muted, fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens))
1336 return []string{indent + line}
1337}
1338
1339// StatusBarParams groups the many bits of state the status bar needs.
1340// Grew from a flat argument list once we settled on the layout.
1341type StatusBarParams struct {
1342 Theme Theme
1343 Provider string
1344 Model string
1345 Busy bool
1346 BusyPrefix string // spinner + funny line when busy
1347 CWD string
1348 Locked bool // sandbox on?
1349
1350 // Cumulative session usage and cost.
1351 Usage provider.Usage
1352 // Subscription is true when the credential is an OAuth token (claude
1353 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
1354 // compute a cost for visibility and append "(sub)" so the user
1355 // knows no real money moved.
1356 Subscription bool
1357
1358 // Last turn's input+cache tokens (approximates current live context).
1359 ContextUsed int
1360 ContextMax int // model's context window; 0 disables the percentage
1361
1362 // AutoCompacting is true when the agent is currently running a
1363 // model-triggered condense pass. Surfaces as "(auto)" after the
1364 // context percentage so it's clear where the spinner is coming from.
1365 AutoCompacting bool
1366
1367 // Telegram true when the telegram bridge is connected. Adds a
1368 // small "- tg -" tag to the cwd line so the user can tell at a
1369 // glance that dms are being mirrored into this session.
1370 Telegram bool
1371
1372 Cols int // terminal width; drives right-alignment of cwd
1373}
1374
1375// StatusBar builds the status shown above the editor. Always returns
1376// two lines when a cwd is provided: the stats on the first line, the
1377// cwd on its own line below, indented to match the stats column. This
1378// keeps the status bar stable across terminal resizes (the cwd never
1379// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
1380// makes a long cwd safe at any width.
1381//
1382// Layout:
1383//
1384// <busyPrefix> (provider) model stats <- line 1
1385// cwd <- line 2 (2-space indent)
1386//
1387// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
1388// The slash-command popup and the queued/sliding-in chips already
1389// cover the discoverability of those keybindings.
1390func StatusBar(p StatusBarParams) []string {
1391 th := p.Theme
1392
1393 // Token stats: only include each segment when non-zero. Keeps
1394 // the bar compact on brand-new sessions.
1395 var stats []string
1396 if p.Usage.InputTokens > 0 {
1397 stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
1398 }
1399 if p.Usage.OutputTokens > 0 {
1400 stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
1401 }
1402 if p.Usage.CacheReadTokens > 0 {
1403 stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
1404 }
1405 if p.Usage.CacheWriteTokens > 0 {
1406 stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
1407 }
1408
1409 // Cost: always show the dollar value computed from token counts,
1410 // even on subscription. Lets you see what the equivalent api cost
1411 // would be (handy for gauging subscription value). Append "(sub)"
1412 // only as a hint that no real money moved.
1413 var costStr string
1414 if p.Usage.CostUSD > 0 || p.Subscription {
1415 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
1416 if p.Subscription {
1417 costStr += " (sub)"
1418 }
1419 }
1420 if costStr != "" {
1421 stats = append(stats, costStr)
1422 }
1423
1424 // Context %. Color-coded: yellow >70, red >90.
1425 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
1426 if ctx != "" {
1427 if p.AutoCompacting {
1428 ctx += " (auto)"
1429 }
1430 stats = append(stats, th.FG256(ctxColor, ctx))
1431 }
1432
1433 // Layout uses exactly 2 spaces of horizontal padding everywhere:
1434 // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
1435 // matches the editor prompt's left inset so the bar lines up
1436 // vertically with the conversation column.
1437 const pad = " " // 2 spaces
1438
1439 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
1440 middle := strings.Join(stats, " ")
1441
1442 var leftBuilder strings.Builder
1443 if p.BusyPrefix != "" {
1444 // Don't re-color the payload itself — the caller already set
1445 // fg colors on the spinner glyph, message, and elapsed
1446 // segments. Wrapping the whole thing here would override
1447 // those choices. The pad itself needs no color (it's spaces).
1448 leftBuilder.WriteString(pad + p.BusyPrefix)
1449 // Exactly one pad (2 spaces) between the busy segment and
1450 // the provider/model block. The leading pad above covers
1451 // the left indent.
1452 leftBuilder.WriteString(pad)
1453 } else {
1454 // Idle path: a single pad of left inset so the line
1455 // aligns with the conversation column on its left edge
1456 // (" you" / " zot" message markers). Without the busy
1457 // prefix there's no trailing separator to double-pad.
1458 leftBuilder.WriteString(pad)
1459 }
1460 leftBuilder.WriteString(th.FG256(th.Muted, left))
1461 if middle != "" {
1462 leftBuilder.WriteString(pad)
1463 // `middle` already has colorized context segments; wrap the rest in muted.
1464 leftBuilder.WriteString(th.FG256(th.Muted, middle))
1465 }
1466
1467 cwd := shortenHome(p.CWD)
1468 tags := ""
1469 if p.Locked {
1470 tags += "- jailed "
1471 }
1472 if p.Telegram {
1473 tags += "- tg "
1474 }
1475 if tags != "" && cwd != "" {
1476 cwd = tags + "- " + cwd
1477 }
1478
1479 primary := leftBuilder.String()
1480
1481 // On narrow terminals the single line wraps badly. If the visible
1482 // width exceeds cols and we have a busy prefix, split: keep the
1483 // busy prefix on line 1, put model+stats on line 2.
1484 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
1485 busyLine := pad + p.BusyPrefix
1486 var infoBuilder strings.Builder
1487 infoBuilder.WriteString(pad)
1488 infoBuilder.WriteString(th.FG256(th.Muted, left))
1489 if middle != "" {
1490 infoBuilder.WriteString(pad)
1491 infoBuilder.WriteString(th.FG256(th.Muted, middle))
1492 }
1493 lines := []string{busyLine, infoBuilder.String()}
1494 if cwd != "" {
1495 lines = append(lines, pad+th.FG256(th.Muted, cwd))
1496 }
1497 return lines
1498 }
1499
1500 if cwd == "" {
1501 return []string{primary}
1502 }
1503
1504 // Second line: indent with the same 2-space pad so the cwd lines
1505 // up under the "(provider)" column on line 1.
1506 cwdRendered := pad + th.FG256(th.Muted, cwd)
1507 return []string{primary, cwdRendered}
1508}
1509
1510// contextUsage renders the "N%/ctxMax" fragment, returning the
1511// rendered string plus the colour to wrap it in.
1512func contextUsage(th Theme, used, max int) (string, int) {
1513 if max <= 0 {
1514 if used <= 0 {
1515 return "", th.Muted
1516 }
1517 return formatTokens(used), th.Muted
1518 }
1519 pct := float64(used) / float64(max) * 100
1520 text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
1521 switch {
1522 case pct > 90:
1523 return text, th.Error
1524 case pct > 70:
1525 return text, th.Warning
1526 }
1527 return text, th.Muted
1528}
1529
1530// formatTokens footer formatter:
1531//
1532// < 1000 -> "42"
1533// < 10_000 -> "2.7k"
1534// < 1_000_000 -> "35k"
1535// < 10M -> "1.1M"
1536// else -> "12M"
1537func formatTokens(n int) string {
1538 switch {
1539 case n < 0:
1540 return "0"
1541 case n < 1000:
1542 return fmt.Sprintf("%d", n)
1543 case n < 10000:
1544 return fmt.Sprintf("%.1fk", float64(n)/1000)
1545 case n < 1_000_000:
1546 return fmt.Sprintf("%dk", (n+500)/1000)
1547 case n < 10_000_000:
1548 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
1549 default:
1550 return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
1551 }
1552}
1553
1554// shortenHome replaces the user's $HOME prefix with "~" for readability.
1555func shortenHome(p string) string {
1556 if p == "" {
1557 return ""
1558 }
1559 home := osUserHome()
1560 if home == "" {
1561 return p
1562 }
1563 if p == home {
1564 return "~"
1565 }
1566 if strings.HasPrefix(p, home+"/") {
1567 return "~" + p[len(home):]
1568 }
1569 return p
1570}
1571
1572// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
1573var osUserHome = func() string {
1574 if h, err := osUserHomeDir(); err == nil {
1575 return h
1576 }
1577 return ""
1578}