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 // Bash-result styling: when the first row looks like a shell
590 // prompt line ("$ ...") emitted by the bash tool, style the
591 // prompt line in accent and the trailing "[exit N] Took X.Ys"
592 // line in muted type so the command + timing read at a glance.
593 // Everything between is left on the default tool-output color.
594 if len(lines) > 0 && strings.HasPrefix(lines[0], "$ ") {
595 return v.renderBashResult(lines, width, defaultColor)
596 }
597
598 inDiff := false
599 oldLine, newLine := 1, 1
600 var out []string
601 for _, l := range lines {
602 // Detect diff header: "--- name" followed somewhere by "+++ name".
603 if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
604 inDiff = true
605 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
606 continue
607 }
608 // Hunk header "@@ -a,b +c,d @@" resets the counters so patches
609 // that skip around in the file still get correct numbering.
610 if inDiff && strings.HasPrefix(l, "@@") {
611 if o, n, ok := parseHunkHeader(l); ok {
612 oldLine, newLine = o, n
613 }
614 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
615 continue
616 }
617 if inDiff && len(l) > 0 {
618 switch l[0] {
619 case '+':
620 out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
621 newLine++
622 continue
623 case '-':
624 out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
625 oldLine++
626 continue
627 case ' ':
628 out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
629 oldLine++
630 newLine++
631 continue
632 }
633 }
634 // Regular line.
635 for _, w := range wrapLine(l, width-4, " ") {
636 out = append(out, " "+v.Theme.FG256(defaultColor, w))
637 }
638 }
639 return out
640}
641
642// parseHunkHeader extracts the starting old/new line from a unified
643// diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the
644// header is malformed or missing numbers.
645func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
646 // Skip "@@ "
647 rest := strings.TrimPrefix(l, "@@")
648 rest = strings.TrimSpace(rest)
649 if !strings.HasPrefix(rest, "-") {
650 return 0, 0, false
651 }
652 rest = rest[1:]
653 space := strings.IndexByte(rest, ' ')
654 if space < 0 {
655 return 0, 0, false
656 }
657 oldPart := rest[:space]
658 rest = strings.TrimSpace(rest[space+1:])
659 if !strings.HasPrefix(rest, "+") {
660 return 0, 0, false
661 }
662 rest = rest[1:]
663 if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
664 rest = rest[:sp]
665 }
666 parseStart := func(s string) (int, bool) {
667 if c := strings.IndexByte(s, ','); c >= 0 {
668 s = s[:c]
669 }
670 n, err := strconv.Atoi(s)
671 if err != nil || n < 1 {
672 return 0, false
673 }
674 return n, true
675 }
676 o, ok1 := parseStart(oldPart)
677 n, ok2 := parseStart(rest)
678 if !ok1 || !ok2 {
679 return 0, 0, false
680 }
681 return o, n, true
682}
683
684// renderDiffRow renders one unified-diff line with a read-style gutter
685// (6-cell right-aligned line number, muted) followed by the +/-/space
686// marker and the code. Code is syntax-highlighted if sourcePath hints
687// at a known language; falls back to the plain diff color otherwise.
688func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string {
689 if len(line) == 0 {
690 return ""
691 }
692 code := line[1:] // strip the leading marker; we re-emit it in colour
693
694 // Syntax-highlight the code half when we know the language. Use
695 // the same HighlightCode pipeline as renderNumberedFile so the
696 // palette matches.
697 lang := LanguageFromPath(sourcePath)
698 var codeRendered string
699 if lang != "" {
700 if h := HighlightCode(code, lang); len(h) == 1 {
701 codeRendered = h[0]
702 }
703 }
704 if codeRendered == "" {
705 codeRendered = v.Theme.FG256(color, code)
706 }
707
708 gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
709 marker := v.Theme.FG256(color, string(mark)+" ")
710 row := " " + gutter + marker + codeRendered
711
712 // Cheap width clamp: truncate visible text if the raw code is too
713 // long. We work on the pre-ANSI code string because measuring ansi
714 // output is unreliable.
715 maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
716 if maxCode > 0 && len(code) > maxCode {
717 trunc := code[:maxCode-1] + "…"
718 if lang != "" {
719 if h := HighlightCode(trunc, lang); len(h) == 1 {
720 codeRendered = h[0]
721 } else {
722 codeRendered = v.Theme.FG256(color, trunc)
723 }
724 } else {
725 codeRendered = v.Theme.FG256(color, trunc)
726 }
727 row = " " + gutter + marker + codeRendered
728 }
729 return row
730}
731
732// renderImageBlock returns the lines for one image, inline if possible.
733//
734// Inline image escapes paint into multiple terminal rows but the zot
735// renderer treats each slice entry as a single row. To prevent chat
736// content from being drawn on top of the image, we pad with blank rows
737// so the image's real footprint is reflected in the frame height.
738func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
739 w, h := ImageDimensions(b.Data)
740 kb := len(b.Data) / 1024
741 info := fmt.Sprintf(" image · %s · %dx%d · %d KB", b.MimeType, w, h, kb)
742
743 if v.ImageProto != ImageProtocolNone {
744 // Clamp rendered width so the image never overflows the chat
745 // column. Subtract a 4-cell indent for the tool result block,
746 // cap at 60 cells, floor at 10.
747 cells := width - 4
748 if cells > 60 {
749 cells = 60
750 }
751 if cells < 10 {
752 cells = 10
753 }
754 const maxRows = 20
755 if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" {
756 rows := RowsForInlineImage(b.Data, cells, maxRows)
757 if rows < 1 {
758 rows = 1
759 }
760 out := make([]string, 0, rows+1)
761 out = append(out, " "+seq)
762 // Reserve blank rows matching the image's on-screen height.
763 for i := 1; i < rows; i++ {
764 out = append(out, "")
765 }
766 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
767 return out
768 }
769 }
770 // Text fallback.
771 return []string{v.Theme.FG256(v.Theme.Muted, info)}
772}
773
774// looksLikeNumberedFile returns true when text matches the `read`
775// tool's " N\tcontent" format for most of its lines.
776func looksLikeNumberedFile(text string) bool {
777 lines := strings.Split(text, "\n")
778 if len(lines) == 0 {
779 return false
780 }
781 hits := 0
782 scanned := 0
783 for _, l := range lines {
784 if l == "" {
785 continue
786 }
787 scanned++
788 if scanned > 20 {
789 break
790 }
791 if numberedLineRE.MatchString(l) {
792 hits++
793 }
794 }
795 if scanned == 0 {
796 return false
797 }
798 return hits*2 >= scanned // majority of non-empty lines match
799}
800
801var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
802
803// renderNumberedFile strips line numbers, highlights the code, and
804// re-attaches the line numbers in muted color.
805func (v *View) renderNumberedFile(text, sourcePath string) []string {
806 lines := strings.Split(text, "\n")
807 gutters := make([]string, 0, len(lines))
808 codes := make([]string, 0, len(lines))
809 for _, l := range lines {
810 idx := strings.IndexByte(l, '\t')
811 if idx < 0 || !numberedLineRE.MatchString(l) {
812 // Non-code footer (e.g. "[truncated at 2000 lines]").
813 gutters = append(gutters, "")
814 codes = append(codes, l)
815 continue
816 }
817 gutter := l[:idx+1] // keep the tab so alignment is preserved
818 code := l[idx+1:]
819 gutters = append(gutters, gutter)
820 codes = append(codes, code)
821 }
822 lang := LanguageFromPath(sourcePath)
823 var highlighted []string
824 if lang != "" {
825 highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
826 // Chroma sometimes collapses the trailing empty line. Pad to
827 // align with the gutter slice so per-line zipping works.
828 for len(highlighted) < len(codes) {
829 highlighted = append(highlighted, "")
830 }
831 if len(highlighted) > len(codes) {
832 highlighted = highlighted[:len(codes)]
833 }
834 } else {
835 // No lexer for this file type — render in the ToolOut color so
836 // code is visually distinct from the muted gutter.
837 highlighted = make([]string, len(codes))
838 for i, c := range codes {
839 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
840 }
841 }
842 out := make([]string, 0, len(codes))
843 for i, code := range highlighted {
844 g := gutters[i]
845 if g == "" {
846 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
847 continue
848 }
849 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
850 }
851 return out
852}
853
854// looksLikeFileContent is a cheap guard to distinguish a read-tool
855// result from bash stdout or a status message. File content usually
856// contains characters that status messages don't (code punctuation,
857// longer lines, multiple lines) and rarely starts with the " >"-
858// or "error:"-style prefixes tools emit. False positives are OK,
859// the worst case is a line-number gutter on something that isn't
860// really code.
861// renderBashResult styles a bash tool result: the "$ command" first
862// line in the accent color, the trailing "[exit N] Took X.Ys" line
863// in muted type, everything else on the default tool-output color.
864// Called from renderToolText when the first line starts with "$ ".
865func (v *View) renderBashResult(lines []string, width, defaultColor int) []string {
866 // Identify the footer line (exit + timing). The bash tool writes
867 // it as the last non-empty line of the result.
868 footerIdx := -1
869 for i := len(lines) - 1; i >= 0; i-- {
870 trimmed := strings.TrimSpace(lines[i])
871 if trimmed == "" {
872 continue
873 }
874 if strings.HasPrefix(trimmed, "[exit ") {
875 footerIdx = i
876 }
877 break
878 }
879
880 var out []string
881 for i, l := range lines {
882 switch {
883 case i == 0 && strings.HasPrefix(l, "$ "):
884 // Style the $ and the command text in accent. No
885 // further processing / wrapping so the shell-style
886 // prompt reads at a glance.
887 for _, w := range wrapLine(l, width-4, " ") {
888 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
889 }
890 case i == footerIdx:
891 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
892 default:
893 for _, w := range wrapLine(l, width-4, " ") {
894 out = append(out, " "+v.Theme.FG256(defaultColor, w))
895 }
896 }
897 }
898 return out
899}
900
901func looksLikeFileContent(text string) bool {
902 if strings.TrimSpace(text) == "" {
903 return false
904 }
905 lines := strings.Split(text, "\n")
906 return len(lines) >= 2
907}
908
909// renderRawFile renders file content received without embedded line
910// numbers (the current read-tool output). Draws a muted gutter like
911// "%6d \t" starting at startLine, highlights the code using the
912// source path's language, and returns the formatted lines.
913func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
914 lines := strings.Split(text, "\n")
915 // Drop the trailing empty line that Split produces when text ends
916 // in "\n" so the gutter doesn't show a phantom last number.
917 if n := len(lines); n > 0 && lines[n-1] == "" {
918 lines = lines[:n-1]
919 }
920 // Split code from trailing footer lines ("... [truncated ...]")
921 // so we don't number the footer.
922 codeEnd := len(lines)
923 for i := len(lines) - 1; i >= 0; i-- {
924 if strings.HasPrefix(lines[i], "...") {
925 codeEnd = i
926 continue
927 }
928 break
929 }
930 code := lines[:codeEnd]
931 footer := lines[codeEnd:]
932
933 lang := LanguageFromPath(sourcePath)
934 var highlighted []string
935 if lang != "" {
936 highlighted = HighlightCode(strings.Join(code, "\n"), lang)
937 for len(highlighted) < len(code) {
938 highlighted = append(highlighted, "")
939 }
940 if len(highlighted) > len(code) {
941 highlighted = highlighted[:len(code)]
942 }
943 } else {
944 highlighted = make([]string, len(code))
945 for i, c := range code {
946 highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
947 }
948 }
949
950 out := make([]string, 0, len(lines))
951 for i, c := range highlighted {
952 gutter := fmt.Sprintf("%6d\t", startLine+i)
953 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
954 }
955 for _, f := range footer {
956 out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
957 }
958 return out
959}
960
961func toolResultBlock(th Theme, text string, width int, color int) []string {
962 rule := th.FG256(th.Muted, strings.Repeat("─", width))
963
964 var out []string
965 out = append(out, rule)
966 for _, l := range strings.Split(text, "\n") {
967 for _, w := range wrapLine(l, width-4, " ") {
968 out = append(out, " "+th.FG256(color, w))
969 }
970 }
971 out = append(out, rule)
972 return out
973}
974
975func shortArgs(raw json.RawMessage) string {
976 var v any
977 if err := json.Unmarshal(raw, &v); err != nil {
978 return ""
979 }
980 switch x := v.(type) {
981 case map[string]any:
982 for _, k := range []string{"path", "file_path", "command"} {
983 if s, ok := x[k].(string); ok {
984 if len(s) > 60 {
985 s = s[:57] + "..."
986 }
987 return s
988 }
989 }
990 }
991 b, _ := json.Marshal(v)
992 s := string(b)
993 if len(s) > 60 {
994 s = s[:57] + "..."
995 }
996 return s
997}
998
999func collectText(blocks []provider.Content) string {
1000 var sb strings.Builder
1001 for _, b := range blocks {
1002 if tb, ok := b.(provider.TextBlock); ok {
1003 if sb.Len() > 0 {
1004 sb.WriteString("\n")
1005 }
1006 sb.WriteString(tb.Text)
1007 }
1008 }
1009 return sb.String()
1010}
1011
1012func truncateLines(s string, n int) string {
1013 lines := strings.Split(s, "\n")
1014 if len(lines) <= n {
1015 return s
1016 }
1017 return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)"
1018}
1019
1020// StatusBarParams groups the many bits of state the status bar needs.
1021// Grew from a flat argument list once we settled on the layout.
1022type StatusBarParams struct {
1023 Theme Theme
1024 Provider string
1025 Model string
1026 Busy bool
1027 BusyPrefix string // spinner + funny line when busy
1028 CWD string
1029 Locked bool // sandbox on?
1030
1031 // Cumulative session usage and cost.
1032 Usage provider.Usage
1033 // Subscription is true when the credential is an OAuth token (claude
1034 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
1035 // compute a cost for visibility and append "(sub)" so the user
1036 // knows no real money moved.
1037 Subscription bool
1038
1039 // Last turn's input+cache tokens (approximates current live context).
1040 ContextUsed int
1041 ContextMax int // model's context window; 0 disables the percentage
1042
1043 // AutoCompacting is true when the agent is currently running a
1044 // model-triggered condense pass. Surfaces as "(auto)" after the
1045 // context percentage so it's clear where the spinner is coming from.
1046 AutoCompacting bool
1047
1048 // Telegram true when the telegram bridge is connected. Adds a
1049 // small "· tg ·" tag to the cwd line so the user can tell at a
1050 // glance that dms are being mirrored into this session.
1051 Telegram bool
1052
1053 Cols int // terminal width; drives right-alignment of cwd
1054}
1055
1056// StatusBar builds the status shown above the editor. Always returns
1057// two lines when a cwd is provided: the stats on the first line, the
1058// cwd on its own line below, indented to match the stats column. This
1059// keeps the status bar stable across terminal resizes (the cwd never
1060// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
1061// makes a long cwd safe at any width.
1062//
1063// Layout:
1064//
1065// <busyPrefix> (provider) model stats <- line 1
1066// cwd <- line 2 (2-space indent)
1067//
1068// The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
1069// The slash-command popup and the queued/sliding-in chips already
1070// cover the discoverability of those keybindings.
1071func StatusBar(p StatusBarParams) []string {
1072 th := p.Theme
1073
1074 // Token stats: only include each segment when non-zero. Keeps
1075 // the bar compact on brand-new sessions.
1076 var stats []string
1077 if p.Usage.InputTokens > 0 {
1078 stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
1079 }
1080 if p.Usage.OutputTokens > 0 {
1081 stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
1082 }
1083 if p.Usage.CacheReadTokens > 0 {
1084 stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
1085 }
1086 if p.Usage.CacheWriteTokens > 0 {
1087 stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
1088 }
1089
1090 // Cost: always show the dollar value computed from token counts,
1091 // even on subscription. Lets you see what the equivalent api cost
1092 // would be (handy for gauging subscription value). Append "(sub)"
1093 // only as a hint that no real money moved.
1094 var costStr string
1095 if p.Usage.CostUSD > 0 || p.Subscription {
1096 costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
1097 if p.Subscription {
1098 costStr += " (sub)"
1099 }
1100 }
1101 if costStr != "" {
1102 stats = append(stats, costStr)
1103 }
1104
1105 // Context %. Color-coded: yellow >70, red >90.
1106 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
1107 if ctx != "" {
1108 if p.AutoCompacting {
1109 ctx += " (auto)"
1110 }
1111 stats = append(stats, th.FG256(ctxColor, ctx))
1112 }
1113
1114 // Layout uses exactly 2 spaces of horizontal padding everywhere:
1115 // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
1116 // matches the editor prompt's left inset so the bar lines up
1117 // vertically with the conversation column.
1118 const pad = " " // 2 spaces
1119
1120 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
1121 middle := strings.Join(stats, " ")
1122
1123 var leftBuilder strings.Builder
1124 if p.BusyPrefix != "" {
1125 // Don't re-color the payload itself — the caller already set
1126 // fg colors on the spinner glyph, message, and elapsed
1127 // segments. Wrapping the whole thing here would override
1128 // those choices. The pad itself needs no color (it's spaces).
1129 leftBuilder.WriteString(pad + p.BusyPrefix)
1130 leftBuilder.WriteString(pad)
1131 }
1132 leftBuilder.WriteString(pad)
1133 leftBuilder.WriteString(th.FG256(th.Muted, left))
1134 if middle != "" {
1135 leftBuilder.WriteString(pad)
1136 // `middle` already has colorized context segments; wrap the rest in muted.
1137 leftBuilder.WriteString(th.FG256(th.Muted, middle))
1138 }
1139
1140 cwd := shortenHome(p.CWD)
1141 tags := ""
1142 if p.Locked {
1143 tags += "· jailed "
1144 }
1145 if p.Telegram {
1146 tags += "· tg "
1147 }
1148 if tags != "" && cwd != "" {
1149 cwd = tags + "· " + cwd
1150 }
1151
1152 primary := leftBuilder.String()
1153 if cwd == "" {
1154 return []string{primary}
1155 }
1156
1157 // Second line: indent with the same 2-space pad so the cwd lines
1158 // up under the "(provider)" column on line 1.
1159 cwdRendered := pad + th.FG256(th.Muted, cwd)
1160 return []string{primary, cwdRendered}
1161}
1162
1163// contextUsage renders the "N%/ctxMax" fragment, returning the
1164// rendered string plus the colour to wrap it in.
1165func contextUsage(th Theme, used, max int) (string, int) {
1166 if max <= 0 {
1167 if used <= 0 {
1168 return "", th.Muted
1169 }
1170 return formatTokens(used), th.Muted
1171 }
1172 pct := float64(used) / float64(max) * 100
1173 text := fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
1174 switch {
1175 case pct > 90:
1176 return text, th.Error
1177 case pct > 70:
1178 return text, th.Warning
1179 }
1180 return text, th.Muted
1181}
1182
1183// formatTokens footer formatter:
1184//
1185// < 1000 -> "42"
1186// < 10_000 -> "2.7k"
1187// < 1_000_000 -> "35k"
1188// < 10M -> "1.1M"
1189// else -> "12M"
1190func formatTokens(n int) string {
1191 switch {
1192 case n < 0:
1193 return "0"
1194 case n < 1000:
1195 return fmt.Sprintf("%d", n)
1196 case n < 10000:
1197 return fmt.Sprintf("%.1fk", float64(n)/1000)
1198 case n < 1_000_000:
1199 return fmt.Sprintf("%dk", (n+500)/1000)
1200 case n < 10_000_000:
1201 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
1202 default:
1203 return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
1204 }
1205}
1206
1207// shortenHome replaces the user's $HOME prefix with "~" for readability.
1208func shortenHome(p string) string {
1209 if p == "" {
1210 return ""
1211 }
1212 home := osUserHome()
1213 if home == "" {
1214 return p
1215 }
1216 if p == home {
1217 return "~"
1218 }
1219 if strings.HasPrefix(p, home+"/") {
1220 return "~" + p[len(home):]
1221 }
1222 return p
1223}
1224
1225// osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it.
1226var osUserHome = func() string {
1227 if h, err := osUserHomeDir(); err == nil {
1228 return h
1229 }
1230 return ""
1231}