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