v0.0.42 ac6d556

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

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