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// pathFromToolArgs returns the "path" argument from a tool_call's
15// JSON arguments, or "" if the args aren't a JSON object or don't
16// include one. Used to pick a syntax language for rendering the
17// corresponding tool_result.
18func pathFromToolArgs(raw json.RawMessage) string {
19 if len(raw) == 0 {
20 return ""
21 }
22 var m map[string]any
23 if err := json.Unmarshal(raw, &m); err != nil {
24 return ""
25 }
26 for _, k := range []string{"path", "file_path"} {
27 if s, ok := m[k].(string); ok && s != "" {
28 return s
29 }
30 }
31 return ""
32}
33
34// offsetFromToolArgs returns the read tool's 1-indexed `offset`
35// arg (the first line of the slice the tool was asked to return),
36// or 0 when the call didn't specify one. Used by the tui to draw
37// the line-number gutter aligned to the right starting row, even
38// though the tool's text content itself no longer carries line
39// numbers.
40func offsetFromToolArgs(raw json.RawMessage) int {
41 if len(raw) == 0 {
42 return 0
43 }
44 var m map[string]any
45 if err := json.Unmarshal(raw, &m); err != nil {
46 return 0
47 }
48 switch v := m["offset"].(type) {
49 case float64:
50 return int(v)
51 case int:
52 return v
53 }
54 return 0
55}
56
57// osUserHomeDir is aliased so the test file can swap it.
58var osUserHomeDir = os.UserHomeDir
59
60// View turns a transcript + live state into a slice of styled lines,
61// already wrapped to width.
62type View struct {
63 Theme Theme
64 ImageProto ImageProtocol // how to render inline images in this terminal
65 Messages []provider.Message
66 // toolPaths maps tool_use_id to the "path" argument of the call, if
67 // any, so tool_result rendering can pick the right syntax language.
68 // Rebuilt on each Build().
69 toolPaths map[string]string
70 // toolStartLines maps tool_use_id to the 1-indexed first line
71 // number of a `read` result, pulled from the call's offset arg.
72 // Used by renderNumberedFile to draw a line-number gutter over
73 // raw (unnumbered) file content the model receives. Rebuilt on
74 // each Build().
75 toolStartLines map[string]int
76 Streaming string // current assistant text delta
77 StreamingActive bool
78 ToolCalls []ToolCallView // tool calls in flight or completed
79 StatusLine string
80 Err string
81
82 // ExpandAll forces every long tool result to render in full.
83 // Toggled from the tui by ctrl+o. When false, results longer than
84 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
85 // "... (N more lines, M total, ctrl+o to expand)" footer.
86 ExpandAll bool
87
88 // renderCache holds the per-message rendered line slices so Build
89 // doesn't re-markdown every message on every frame. Keyed by a
90 // struct of (content hash, width, expandAll) — any of those
91 // changing invalidates the entry. Messages are append-only after
92 // they finalise so keeping the cache across turns is safe.
93 //
94 // Streaming/in-flight work (v.Streaming, v.ToolCalls) is never
95 // cached because it changes every delta.
96 renderCache map[msgCacheKey][]string
97}
98
99// msgCacheKey identifies a cached message render. hash is a 64-bit
100// FNV-1a of the message's content, which is cheap to compute and
101// unambiguous enough for the cache (collisions produce a stale frame,
102// not wrong data, and we recompute on invalidation anyway).
103type msgCacheKey struct {
104 hash uint64
105 width int
106 expandAll bool
107}
108
109// InvalidateRenderCache drops all cached message renders. The tui
110// calls this when the transcript is replaced wholesale (/compact,
111// /clear, session swap) since messages can be replaced in place and
112// a content-hash miss alone doesn't reclaim the old entries.
113func (v *View) InvalidateRenderCache() {
114 v.renderCache = nil
115}
116
117// ToolCollapsePreview is the number of lines shown before a long tool
118// result is replaced with a "... ctrl+o to expand" footer. Tool
119// results shorter than ToolCollapseLines always render in full.
120const (
121 ToolCollapsePreview = 10
122 ToolCollapseLines = 12
123)
124
125// ToolCallView is a pending tool invocation plus optional result.
126type ToolCallView struct {
127 ID string
128 Name string
129 Args string // rendered argument summary
130 Result string // rendered result preview (truncated)
131 Error bool
132 Done bool
133}
134
135// MessageAnchor records where a rendered message starts in the chat
136// line slice. Used by /jump so the dialog can scroll the viewport to
137// the row where a turn's user prompt begins.
138type MessageAnchor struct {
139 MessageIdx int // index into v.Messages
140 Row int // first row of that message in the Build() output
141}
142
143// Build returns the chat log lines for the given width.
144func (v *View) Build(width int) []string {
145 lines, _ := v.BuildWithAnchors(width)
146 return lines
147}
148
149// BuildWithAnchors is like Build but additionally reports the first
150// row occupied by each message in v.Messages. Callers that need to
151// scroll to a specific turn (the /jump dialog) use the anchor slice
152// to map a message index back to a row offset.
153func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
154 v.refreshToolPaths()
155 if v.renderCache == nil {
156 v.renderCache = make(map[msgCacheKey][]string)
157 }
158
159 // Pre-render every message (hits the cache for unchanged ones) so
160 // we can allocate `out` in a single shot with the exact capacity.
161 // Growing via append on a long transcript copies the backing array
162 // log2(N) times; for a 2000-line scrollback that's enough memcpy
163 // to visibly stutter while typing.
164 rendered := make([][]string, len(v.Messages))
165 total := 0
166 for idx, m := range v.Messages {
167 lines := v.renderMessageCached(m, width)
168 rendered[idx] = lines
169 total += len(lines) + 1 // +1 for the blank separator row
170 }
171
172 out := make([]string, 0, total+16)
173 anchors := make([]MessageAnchor, 0, len(v.Messages))
174 for idx := range v.Messages {
175 anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
176 out = append(out, rendered[idx]...)
177 out = append(out, "")
178 }
179 if v.StreamingActive {
180 out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
181 // Stream the partial assistant text through the same markdown
182 // renderer used for finalised messages so code fences, diffs,
183 // lists, and inline styles look the same while streaming and
184 // don't suddenly reflow when the turn ends. Indent matches the
185 // finalised assistant body in renderMessage so the column
186 // stays consistent across the stream/finalise transition.
187 const indent = " "
188 inner := width - len(indent)
189 if inner < 1 {
190 inner = width
191 }
192 md := RenderMarkdown(v.Streaming, v.Theme, inner)
193 for _, l := range strings.Split(md, "\n") {
194 for _, w := range wrapLine(l, inner, "") {
195 out = append(out, indent+w)
196 }
197 }
198 out = append(out, "")
199 }
200 for _, tc := range v.ToolCalls {
201 out = append(out, v.renderToolCall(tc, width)...)
202 out = append(out, "")
203 }
204 if v.Err != "" {
205 out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
206 out = append(out, "")
207 }
208 return out, anchors
209}
210
211// refreshToolPaths rebuilds the tool_use_id -> path map from the
212// current transcript. Called once per Build() so tool result blocks
213// (which may be cached) can look up their syntax language when they
214// were originally rendered. Walking the transcript here is O(N) but
215// cheap compared to markdown/chroma work it enables.
216func (v *View) refreshToolPaths() {
217 v.toolPaths = map[string]string{}
218 v.toolStartLines = map[string]int{}
219 for _, m := range v.Messages {
220 for _, c := range m.Content {
221 if tc, ok := c.(provider.ToolCallBlock); ok {
222 if p := pathFromToolArgs(tc.Arguments); p != "" {
223 v.toolPaths[tc.ID] = p
224 }
225 if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
226 v.toolStartLines[tc.ID] = off
227 }
228 }
229 }
230 }
231}
232
233// renderMessageCached returns the rendered line slice for m, using the
234// cache if the same (content hash, width, expandAll) combination has
235// been rendered before. The slice returned is shared — callers must
236// not mutate it; Build() only ever appends to its own `out` so the
237// shared slice is safe.
238func (v *View) renderMessageCached(m provider.Message, width int) []string {
239 key := msgCacheKey{
240 hash: hashMessage(m),
241 width: width,
242 expandAll: v.ExpandAll,
243 }
244 if v.renderCache != nil {
245 if lines, ok := v.renderCache[key]; ok {
246 return lines
247 }
248 }
249 lines := v.renderMessage(m, width)
250 if v.renderCache != nil {
251 // Bound the cache: 4x the current message count is enough to
252 // survive /compact churn without leaking memory across a very
253 // long session.
254 max := len(v.Messages) * 4
255 if max < 32 {
256 max = 32
257 }
258 if len(v.renderCache) > max {
259 // Drop half the entries. map iteration order gives us a
260 // pseudo-LRU for free.
261 dropped := 0
262 target := len(v.renderCache) / 2
263 for k := range v.renderCache {
264 if dropped >= target {
265 break
266 }
267 delete(v.renderCache, k)
268 dropped++
269 }
270 }
271 v.renderCache[key] = lines
272 }
273 return lines
274}
275
276// hashMessage returns a 64-bit FNV-1a over the role + content blocks
277// of m. Serialising each block to its salient bytes is enough: two
278// messages with the same role and same content render identically.
279func hashMessage(m provider.Message) uint64 {
280 h := fnv64aInit
281 h = fnv64aWrite(h, []byte(m.Role))
282 h = fnv64aWriteByte(h, 0)
283 for _, c := range m.Content {
284 switch b := c.(type) {
285 case provider.TextBlock:
286 h = fnv64aWriteByte(h, 't')
287 h = fnv64aWrite(h, []byte(b.Text))
288 case provider.ImageBlock:
289 h = fnv64aWriteByte(h, 'i')
290 h = fnv64aWrite(h, []byte(b.MimeType))
291 h = fnv64aWrite(h, b.Data)
292 case provider.ToolCallBlock:
293 h = fnv64aWriteByte(h, 'c')
294 h = fnv64aWrite(h, []byte(b.ID))
295 h = fnv64aWrite(h, []byte(b.Name))
296 h = fnv64aWrite(h, []byte(b.Arguments))
297 case provider.ToolResultBlock:
298 h = fnv64aWriteByte(h, 'r')
299 h = fnv64aWrite(h, []byte(b.CallID))
300 if b.IsError {
301 h = fnv64aWriteByte(h, 'E')
302 }
303 for _, inner := range b.Content {
304 switch ib := inner.(type) {
305 case provider.TextBlock:
306 h = fnv64aWrite(h, []byte(ib.Text))
307 case provider.ImageBlock:
308 h = fnv64aWrite(h, []byte(ib.MimeType))
309 h = fnv64aWrite(h, ib.Data)
310 }
311 }
312 }
313 h = fnv64aWriteByte(h, 0)
314 }
315 return h
316}
317
318// FNV-1a implementation inlined so we don't pay the interface cost of
319// hash.Hash64 on every Build(). The whole point here is speed.
320const (
321 fnv64aInit uint64 = 0xcbf29ce484222325
322 fnv64aPrime uint64 = 0x100000001b3
323)
324
325func fnv64aWriteByte(h uint64, b byte) uint64 {
326 h ^= uint64(b)
327 h *= fnv64aPrime
328 return h
329}
330
331func fnv64aWrite(h uint64, p []byte) uint64 {
332 for _, b := range p {
333 h ^= uint64(b)
334 h *= fnv64aPrime
335 }
336 return h
337}
338
339func (v *View) renderMessage(m provider.Message, width int) []string {
340 var lines []string
341 switch m.Role {
342 case provider.RoleUser:
343 header := v.Theme.FG256(v.Theme.User, "▍ you")
344 lines = append(lines, header)
345 for _, c := range m.Content {
346 switch b := c.(type) {
347 case provider.TextBlock:
348 for _, l := range strings.Split(b.Text, "\n") {
349 for _, w := range wrapLine(l, width-2, " ") {
350 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
351 }
352 }
353 case provider.ImageBlock:
354 lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
355 }
356 }
357 case provider.RoleAssistant:
358 header := v.Theme.FG256(v.Theme.Assistant, "▍ zot")
359 lines = append(lines, header)
360 // Indent assistant body the same 4 cells the user body uses,
361 // so the conversation column lines up vertically. The width
362 // passed into the markdown renderer / wrap is reduced by the
363 // indent so long lines wrap inside the indented column.
364 const indent = " "
365 inner := width - len(indent)
366 if inner < 1 {
367 inner = width
368 }
369 for _, c := range m.Content {
370 switch b := c.(type) {
371 case provider.TextBlock:
372 md := RenderMarkdown(b.Text, v.Theme, inner)
373 for _, l := range strings.Split(md, "\n") {
374 for _, w := range wrapLine(l, inner, "") {
375 lines = append(lines, indent+w)
376 }
377 }
378 case provider.ToolCallBlock:
379 lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
380 }
381 }
382 case provider.RoleTool:
383 for _, c := range m.Content {
384 if tr, ok := c.(provider.ToolResultBlock); ok {
385 title := " result"
386 color := v.Theme.ToolOut
387 if tr.IsError {
388 title = " error"
389 color = v.Theme.Error
390 }
391 path := ""
392 if v.toolPaths != nil {
393 path = v.toolPaths[tr.CallID]
394 }
395 startLine := 1
396 if v.toolStartLines != nil {
397 if s := v.toolStartLines[tr.CallID]; s > 0 {
398 startLine = s
399 }
400 }
401 lines = append(lines, v.Theme.FG256(color, title))
402 lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)
403 }
404 }
405 }
406 return lines
407}
408
409func (v *View) renderToolCall(tc ToolCallView, width int) []string {
410 var lines []string
411 head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+tc.Args)
412 lines = append(lines, head)
413 if tc.Result != "" {
414 color := v.Theme.ToolOut
415 if tc.Error {
416 color = v.Theme.Error
417 }
418 block := toolResultBlock(v.Theme, tc.Result, width, color)
419 // Strip rules, collapse the body, put rules back on.
420 if len(block) >= 2 {
421 top, bot := block[0], block[len(block)-1]
422 body := v.collapseToolBody(block[1:len(block)-1], false)
423 block = append([]string{top}, body...)
424 block = append(block, bot)
425 }
426 lines = append(lines, block...)
427 }
428 return lines
429}
430
431// toolResultBlock wraps text in thin horizontal rules (top + bottom),
432// indenting the body with four spaces. The rules span the content column.
433// renderToolResultContent renders the body of a tool result block.
434// Text blocks get the usual rules-wrapped treatment; text that looks
435// like a unified diff gets +/- coloring. Image blocks are rendered
436// inline when the terminal supports a protocol, else as a text
437// placeholder with dimensions.
438func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
439 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
440
441 var body []string
442 hasImage := false
443 for _, b := range blocks {
444 switch bb := b.(type) {
445 case provider.TextBlock:
446 body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
447 case provider.ImageBlock:
448 hasImage = true
449 body = append(body, v.renderImageBlock(bb, width)...)
450 }
451 }
452 body = v.collapseToolBody(body, hasImage)
453
454 out := make([]string, 0, len(body)+2)
455 out = append(out, rule)
456 out = append(out, body...)
457 out = append(out, rule)
458 return out
459}
460
461// collapseToolBody trims lines to the configured preview size when the
462// view is not in ExpandAll mode, appending a muted "... ctrl+o to
463// expand" footer. Image blocks never collapse — they're short in text
464// rows but represent real content the user wants to see.
465func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
466 if v.ExpandAll || hasImage {
467 return lines
468 }
469 if len(lines) <= ToolCollapseLines {
470 return lines
471 }
472 kept := lines[:ToolCollapsePreview]
473 hidden := len(lines) - ToolCollapsePreview
474 total := len(lines)
475 footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total)
476 footer = v.Theme.FG256(v.Theme.Muted, footer)
477 return append(append([]string(nil), kept...), footer)
478}
479
480// renderToolText renders a text block inside a tool result. If the
481// text contains a unified-diff section (lines starting with "--- " /
482// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
483// colors matching git diff conventions.
484func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
485 // Legacy path: transcripts saved before we dropped line numbers
486 // from the read tool still carry " 1\t…" prefixes. Detect and
487 // strip them, then fall through to the highlighter.
488 if looksLikeNumberedFile(text) {
489 return v.renderNumberedFile(text, sourcePath)
490 }
491 // Current path: text came from `read` as raw file bytes. When a
492 // source path is known (the call had a `path` arg), render with
493 // a synthetic line-number gutter starting at startLine so the
494 // on-screen view still looks like cat -n. Doesn't apply to non-
495 // file tool outputs (bash stdout, display notes, etc.).
496 if sourcePath != "" && looksLikeFileContent(text) {
497 return v.renderRawFile(text, sourcePath, startLine)
498 }
499
500 // No truncation — the full tool output is rendered into chat and
501 // becomes part of the scrollback you can page back through.
502 lines := strings.Split(text, "\n")
503
504 inDiff := false
505 oldLine, newLine := 1, 1
506 var out []string
507 for _, l := range lines {
508 // Detect diff header: "--- name" followed somewhere by "+++ name".
509 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
510 inDiff = true
511 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
512 continue
513 }
514 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
515 // that skip around in the file still get correct numbering.
516 if inDiff && strings.HasPrefix(l, "@@") {
517 if o, n, ok := parseHunkHeader(l); ok {
518 oldLine, newLine = o, n
519 }
520 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
521 continue
522 }
523 if inDiff && len(l) > 0 {
524 switch l[0] {
525 case '+':
526 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
527 newLine++
528 continue
529 case '-':
530 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
531 oldLine++
532 continue
533 case ' ':
534 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
535 oldLine++
536 newLine++
537 continue
538 }
539 }
540 // Regular line.
541 for _, w := range wrapLine(l, width-4, " ") {
542 out = append(out, " "+v.Theme.FG256(defaultColor, w))
543 }
544 }
545 return out
546}
547
548// parseHunkHeader extracts the starting old/new line from a unified
549// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
550// header is malformed or missing numbers.
551func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
552 // Skip "@@ "
553 rest := strings.TrimPrefix(l, "@@")
554 rest = strings.TrimSpace(rest)
555 if !strings.HasPrefix(rest, "-") {
556 return 0, 0, false
557 }
558 rest = rest[1:]
559 space := strings.IndexByte(rest, ' ')
560 if space < 0 {
561 return 0, 0, false
562 }
563 oldPart := rest[:space]
564 rest = strings.TrimSpace(rest[space+1:])
565 if !strings.HasPrefix(rest, "+") {
566 return 0, 0, false
567 }
568 rest = rest[1:]
569 if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
570 rest = rest[:sp]
571 }
572 parseStart := func(s string) (int, bool) {
573 if c := strings.IndexByte(s, ','); c >= 0 {
574 s = s[:c]
575 }
576 n, err := strconv.Atoi(s)
577 if err != nil || n < 1 {
578 return 0, false
579 }
580 return n, true
581 }
582 o, ok1 := parseStart(oldPart)
583 n, ok2 := parseStart(rest)
584 if !ok1 || !ok2 {
585 return 0, 0, false
586 }
587 return o, n, true
588}
589
590// renderDiffRow renders one unified-diff line with a read-style gutter
591// (6-cell right-aligned line number, muted) followed by the +/-/space
592// marker and the code. Code is syntax-highlighted if sourcePath hints
593// at a known language; falls back to the plain diff color otherwise.
594func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
595 if len(line) == 0 {
596 return ""
597 }
598 code := line[1:] // strip the leading marker; we re-emit it in colour
599
600 // Syntax-highlight the code half when we know the language. Use
601 // the same HighlightCode pipeline as renderNumberedFile so the
602 // palette matches.
603 lang := LanguageFromPath(sourcePath)
604 var codeRendered string
605 if lang != "" {
606 if h := HighlightCode(code, lang); len(h) == 1 {
607 codeRendered = h[0]
608 }
609 }
610 if codeRendered == "" {
611 codeRendered = v.Theme.FG256(color, code)
612 }
613
614 gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
615 marker := v.Theme.FG256(color, string(mark)+" ")
616 row := " " + gutter + marker + codeRendered
617
618 // Cheap width clamp: truncate visible text if the raw code is too
619 // long. We work on the pre-ANSI code string because measuring ansi
620 // output is unreliable.
621 maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
622 if maxCode > 0 && len(code) > maxCode {
623 trunc := code[:maxCode-1] + "…"
624 if lang != "" {
625 if h := HighlightCode(trunc, lang); len(h) == 1 {
626 codeRendered = h[0]
627 } else {
628 codeRendered = v.Theme.FG256(color, trunc)
629 }
630 } else {
631 codeRendered = v.Theme.FG256(color, trunc)
632 }
633 row = " " + gutter + marker + codeRendered
634 }
635 return row
636}
637
638// renderImageBlock returns the lines for one image, inline if possible.
639//
640// Inline image escapes paint into multiple terminal rows but the zot
641// renderer treats each slice entry as a single row. To prevent chat
642// content from being drawn on top of the image, we pad with blank rows
643// so the image's real footprint is reflected in the frame height.
644func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
645 w, h := ImageDimensions(b.Data)
646 kb := len(b.Data) / 1024
647 info := fmt.Sprintf(" image · %s · %dx%d · %d KB", b.MimeType, w, h, kb)
648
649 if v.ImageProto != ImageProtocolNone {
650 // Clamp rendered width so the image never overflows the chat
651 // column. Subtract a 4-cell indent for the tool result block,
652 // cap at 60 cells, floor at 10.
653 cells := width - 4
654 if cells > 60 {
655 cells = 60
656 }
657 if cells < 10 {
658 cells = 10
659 }
660 const maxRows = 20
661 if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
662 rows := RowsForInlineImage(b.Data, cells, maxRows)
663 if rows < 1 {
664 rows = 1
665 }
666 out := make([]string, 0, rows+1)
667 out = append(out, " "+seq)
668 // Reserve blank rows matching the image's on-screen height.
669 for i := 1; i < rows; i++ {
670 out = append(out, "")
671 }
672 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
673 return out
674 }
675 }
676 // Text fallback.
677 return []string{v.Theme.FG256(v.Theme.Muted, info)}
678}
679
680// looksLikeNumberedFile returns true when text matches the `read`
681// tool's " N\tcontent" format for most of its lines.
682func looksLikeNumberedFile(text string) bool {
683 lines := strings.Split(text, "\n")
684 if len(lines) == 0 {
685 return false
686 }
687 hits := 0
688 scanned := 0
689 for _, l := range lines {
690 if l == "" {
691 continue
692 }
693 scanned++
694 if scanned > 20 {
695 break
696 }
697 if numberedLineRE.MatchString(l) {
698 hits++
699 }
700 }
701 if scanned == 0 {
702 return false
703 }
704 return hits*2 >= scanned // majority of non-empty lines match
705}
706
707var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
708
709// renderNumberedFile strips line numbers, highlights the code, and
710// re-attaches the line numbers in muted color.
711func (v *View) renderNumberedFile(text, sourcePath string) []string {
712 lines := strings.Split(text, "\n")
713 gutters := make([]string, 0, len(lines))
714 codes := make([]string, 0, len(lines))
715 for _, l := range lines {
716 idx := strings.IndexByte(l, '\t')
717 if idx < 0 || !numberedLineRE.MatchString(l) {
718 // Non-code footer (e.g. "[truncated at 2000 lines]").
719 gutters = append(gutters, "")
720 codes = append(codes, l)
721 continue
722 }
723 gutter := l[:idx+1] // keep the tab so alignment is preserved
724 code := l[idx+1:]
725 gutters = append(gutters, gutter)
726 codes = append(codes, code)
727 }
728 lang := LanguageFromPath(sourcePath)
729 var highlighted []string
730 if lang != "" {
731 highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
732 // Chroma sometimes collapses the trailing empty line. Pad to
733 // align with the gutter slice so per-line zipping works.
734 for len(highlighted) < len(codes) {
735 highlighted = append(highlighted, "")
736 }
737 if len(highlighted) > len(codes) {
738 highlighted = highlighted[:len(codes)]
739 }
740 } else {
741 // No lexer for this file type — render in the ToolOut color so
742 // code is visually distinct from the muted gutter.
743 highlighted = make([]string, len(codes))
744 for i, c := range codes {
745 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
746 }
747 }
748 out := make([]string, 0, len(codes))
749 for i, code := range highlighted {
750 g := gutters[i]
751 if g == "" {
752 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
753 continue
754 }
755 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
756 }
757 return out
758}
759
760// looksLikeFileContent is a cheap guard to distinguish a read-tool
761// result from bash stdout or a status message. File content usually
762// contains characters that status messages don't (code punctuation,
763// longer lines, multiple lines) and rarely starts with the " >"-
764// or "error:"-style prefixes tools emit. False positives are OK,
765// the worst case is a line-number gutter on something that isn't
766// really code.
767func looksLikeFileContent(text string) bool {
768 if strings.TrimSpace(text) == "" {
769 return false
770 }
771 lines := strings.Split(text, "\n")
772 return len(lines) >= 2
773}
774
775// renderRawFile renders file content received without embedded line
776// numbers (the current read-tool output). Draws a muted gutter like
777// "%6d \t" starting at startLine, highlights the code using the
778// source path's language, and returns the formatted lines.
779func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
780 lines := strings.Split(text, "\n")
781 // Drop the trailing empty line that Split produces when text ends
782 // in "\n" so the gutter doesn't show a phantom last number.
783 if n := len(lines); n > 0 && lines[n-1] == "" {
784 lines = lines[:n-1]
785 }
786 // Split code from trailing footer lines ("... [truncated ...]")
787 // so we don't number the footer.
788 codeEnd := len(lines)
789 for i := len(lines) - 1; i >= 0; i-- {
790 if strings.HasPrefix(lines[i], "...") {
791 codeEnd = i
792 continue
793 }
794 break
795 }
796 code := lines[:codeEnd]
797 footer := lines[codeEnd:]
798
799 lang := LanguageFromPath(sourcePath)
800 var highlighted []string
801 if lang != "" {
802 highlighted = HighlightCode(strings.Join(code, "\n"), lang)
803 for len(highlighted) < len(code) {
804 highlighted = append(highlighted, "")
805 }
806 if len(highlighted) > len(code) {
807 highlighted = highlighted[:len(code)]
808 }
809 } else {
810 highlighted = make([]string, len(code))
811 for i, c := range code {
812 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
813 }
814 }
815
816 out := make([]string, 0, len(lines))
817 for i, c := range highlighted {
818 gutter := fmt.Sprintf("%6d\t", startLine+i)
819 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
820 }
821 for _, f := range footer {
822 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
823 }
824 return out
825}
826
827func toolResultBlock(th Theme, text string, width int, color int) []string {
828 rule := th.FG256(th.Muted, strings.Repeat("─", width))
829
830 var out []string
831 out = append(out, rule)
832 for _, l := range strings.Split(text, "\n") {
833 for _, w := range wrapLine(l, width-4, " ") {
834 out = append(out, " "+th.FG256(color, w))
835 }
836 }
837 out = append(out, rule)
838 return out
839}
840
841func shortArgs(raw json.RawMessage) string {
842 var v any
843 if err := json.Unmarshal(raw, &v); err != nil {
844 return ""
845 }
846 switch x := v.(type) {
847 case map[string]any:
848 for _, k := range []string{"path", "file_path", "command"} {
849 if s, ok := x[k].(string); ok {
850 if len(s) > 60 {
851 s = s[:57] + "..."
852 }
853 return s
854 }
855 }
856 }
857 b, _ := json.Marshal(v)
858 s := string(b)
859 if len(s) > 60 {
860 s = s[:57] + "..."
861 }
862 return s
863}
864
865func collectText(blocks []provider.Content) string {
866 var sb strings.Builder
867 for _, b := range blocks {
868 if tb, ok := b.(provider.TextBlock); ok {
869 if sb.Len() > 0 {
870 sb.WriteString("\n")
871 }
872 sb.WriteString(tb.Text)
873 }
874 }
875 return sb.String()
876}
877
878func truncateLines(s string, n int) string {
879 lines := strings.Split(s, "\n")
880 if len(lines) <= n {
881 return s
882 }
883 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
884}
885
886// StatusBarParams groups the many bits of state the status bar needs.
887// Grew from a flat argument list once we settled on the layout.
888type StatusBarParams struct {
889 Theme Theme
890 Provider string
891 Model string
892 Busy bool
893 BusyPrefix string // spinner + funny line when busy
894 CWD string
895 Locked bool // sandbox on?
896
897 // Cumulative session usage and cost.
898 Usage provider.Usage
899 // Subscription is true when the credential is an OAuth token (claude
900 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
901 // compute a cost for visibility and append "(sub)" so the user
902 // knows no real money moved.
903 Subscription bool
904
905 // Last turn's input+cache tokens (approximates current live context).
906 ContextUsed int
907 ContextMax int // model's context window; 0 disables the percentage
908
909 // AutoCompacting is true when the agent is currently running a
910 // model-triggered condense pass. Surfaces as "(auto)" after the
911 // context percentage so it's clear where the spinner is coming from.
912 AutoCompacting bool
913
914 Cols int // terminal width; drives right-alignment of cwd
915}
916
917// StatusBar builds the status shown above the editor. Always returns
918// two lines when a cwd is provided: the stats on the first line, the
919// cwd on its own line below, indented to match the stats column. This
920// keeps the status bar stable across terminal resizes (the cwd never
921// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
922// makes a long cwd safe at any width.
923//
924// Layout:
925//
926// <busyPrefix> (provider) model stats <- line 1
927// cwd <- line 2 (2-space indent)
928//
929// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
930// The slash-command popup and the queued/sliding-in chips already
931// cover the discoverability of those keybindings.
932func StatusBar(p StatusBarParams) []string {
933 th := p.Theme
934
935 // Token stats: only include each segment when non-zero. Keeps
936 // the bar compact on brand-new sessions.
937 var stats []string
938 if p.Usage.InputTokens > 0 {
939 stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
940 }
941 if p.Usage.OutputTokens > 0 {
942 stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
943 }
944 if p.Usage.CacheReadTokens > 0 {
945 stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
946 }
947 if p.Usage.CacheWriteTokens > 0 {
948 stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
949 }
950
951 // Cost: always show the dollar value computed from token counts,
952 // even on subscription. Lets you see what the equivalent api cost
953 // would be (handy for gauging subscription value). Append "(sub)"
954 // only as a hint that no real money moved.
955 var costStr string
956 if p.Usage.CostUSD > 0 || p.Subscription {
957 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
958 if p.Subscription {
959 costStr += " (sub)"
960 }
961 }
962 if costStr != "" {
963 stats = append(stats, costStr)
964 }
965
966 // Context %. Color-coded: yellow >70, red >90.
967 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
968 if ctx != "" {
969 if p.AutoCompacting {
970 ctx += " (auto)"
971 }
972 stats = append(stats, th.FG256(ctxColor, ctx))
973 }
974
975 // Layout uses exactly 2 spaces of horizontal padding everywhere:
976 // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
977 // matches the editor prompt's left inset so the bar lines up
978 // vertically with the conversation column.
979 const pad = " " // 2 spaces
980
981 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
982 middle := strings.Join(stats, " ")
983
984 var leftBuilder strings.Builder
985 if p.BusyPrefix != "" {
986 leftBuilder.WriteString(th.FG256(th.Accent, pad+p.BusyPrefix))
987 leftBuilder.WriteString(pad)
988 }
989 leftBuilder.WriteString(pad)
990 leftBuilder.WriteString(th.FG256(th.Muted, left))
991 if middle != "" {
992 leftBuilder.WriteString(pad)
993 // `middle` already has colorized context segments; wrap the rest in muted.
994 leftBuilder.WriteString(th.FG256(th.Muted, middle))
995 }
996
997 cwd := shortenHome(p.CWD)
998 if p.Locked && cwd != "" {
999 cwd = "· locked · " + cwd
1000 }
1001
1002 primary := leftBuilder.String()
1003 if cwd == "" {
1004 return []string{primary}
1005 }
1006
1007 // Second line: indent with the same 2-space pad so the cwd lines
1008 // up under the "(provider)" column on line 1.
1009 cwdRendered := pad + th.FG256(th.Muted, cwd)
1010 return []string{primary, cwdRendered}
1011}
1012
1013// contextUsage renders the "N%/ctxMax" fragment, returning the
1014// rendered string plus the colour to wrap it in.
1015func contextUsage(th Theme, used, max int) (string, int) {
1016 if max <= 0 {
1017 if used <= 0 {
1018 return "", th.Muted
1019 }
1020 return formatTokens(used), th.Muted
1021 }
1022 pct := float64(used) / float64(max) * 100
1023 text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
1024 switch {
1025 case pct > 90:
1026 return text, th.Error
1027 case pct > 70:
1028 return text, th.Warning
1029 }
1030 return text, th.Muted
1031}
1032
1033// formatTokens footer formatter:
1034//
1035// < 1000 -> "42"
1036// < 10_000 -> "2.7k"
1037// < 1_000_000 -> "35k"
1038// < 10M -> "1.1M"
1039// else -> "12M"
1040func formatTokens(n int) string {
1041 switch {
1042 case n < 0:
1043 return "0"
1044 case n < 1000:
1045 return fmt.Sprintf("%d", n)
1046 case n < 10000:
1047 return fmt.Sprintf("%.1fk", float64(n)/1000)
1048 case n < 1_000_000:
1049 return fmt.Sprintf("%dk", (n+500)/1000)
1050 case n < 10_000_000:
1051 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
1052 default:
1053 return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
1054 }
1055}
1056
1057// shortenHome replaces the user's $HOME prefix with "~" for readability.
1058func shortenHome(p string) string {
1059 if p == "" {
1060 return ""
1061 }
1062 home := osUserHome()
1063 if home == "" {
1064 return p
1065 }
1066 if p == home {
1067 return "~"
1068 }
1069 if strings.HasPrefix(p, home+"/") {
1070 return "~" + p[len(home):]
1071 }
1072 return p
1073}
1074
1075// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
1076var osUserHome = func() string {
1077 if h, err := osUserHomeDir(); err == nil {
1078 return h
1079 }
1080 return ""
1081}