v0.0.54 098a797

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

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