v0.0.24 83ae236

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

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