v0.0.51 c610a3a

view.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close