v0.38.1 969e52a

theme.go RawHistoryFolder

1// Package tui is a small terminal UI: raw-mode input, differential
2// line renderer, multi-line editor, and chat view. No external TUI
3// framework; just ANSI escape codes.
4package tui
5
6import (
7	"strings"
8	"sync/atomic"
9)
10
11// TerminalColor describes both explicit colors and terminal-owned colors.
12// TerminalDefault and TerminalPaletteSlot intentionally differ from literal
13// xterm-256 values: an adaptive theme follows the terminal palette while a
14// custom numeric value retains its xterm meaning.
15type TerminalColor struct {
16	Mode  terminalColorMode
17	Index int
18	R     int
19	G     int
20	B     int
21}
22
23type terminalColorMode int
24
25const (
26	terminalColorDefault terminalColorMode = iota
27	terminalColorPalette
28	terminalColor256
29	terminalColorANSI
30	terminalColorRGB
31)
32
33func TerminalDefault() TerminalColor { return TerminalColor{Mode: terminalColorDefault} }
34func TerminalPaletteSlot(index int) TerminalColor {
35	return TerminalColor{Mode: terminalColorPalette, Index: index}
36}
37func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} }
38func ColorANSI(sgr int) TerminalColor  { return TerminalColor{Mode: terminalColorANSI, Index: sgr} }
39func ColorRGB(r, g, b int) TerminalColor {
40	return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
41}
42
43// ColorDepth is the terminal output capability used when resolving a color.
44type ColorDepth uint8
45
46const (
47	// Indexed256 is the zero value to preserve the legacy behavior for
48	// profiles assembled by callers that predate ColorDepth.
49	ColorDepthIndexed256 ColorDepth = iota
50	ColorDepthANSI16
51	ColorDepthTrueColor
52)
53
54// TerminalProfile is the best-effort color snapshot reported by the
55// controlling terminal. Palette entries are optional; an unknown slot is
56// emitted as its direct ANSI SGR rather than substituted with a fixed zut RGB.
57type TerminalProfile struct {
58	Foreground TerminalColor
59	Background TerminalColor
60
61	HasForeground bool
62	HasBackground bool
63
64	Palette      [16]TerminalColor
65	PaletteKnown uint16
66	Depth        ColorDepth
67	// TrueColor is retained for callers compiled against the older profile.
68	// Depth is authoritative when it is ColorDepthTrueColor.
69	TrueColor   bool
70	Light       bool
71	SchemeKnown bool
72}
73
74func (p TerminalProfile) ColorDepth() ColorDepth {
75	if p.Depth == ColorDepthTrueColor || p.TrueColor {
76		return ColorDepthTrueColor
77	}
78	if p.Depth == ColorDepthANSI16 {
79		return ColorDepthANSI16
80	}
81	return ColorDepthIndexed256
82}
83
84// PaletteColor returns a reported ANSI palette entry when available.
85func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
86	if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 {
87		return TerminalColor{}, false
88	}
89	return p.Palette[index], true
90}
91
92// Semantic palette used by zut. Each role is a TerminalColor so themes can
93// use indexed, ANSI, or RGB values without changing render code.
94type Theme struct {
95	FG           TerminalColor
96	Muted        TerminalColor
97	Accent       TerminalColor
98	Background   *TerminalColor // optional full-row TUI background; nil keeps terminal default
99	User         TerminalColor  // label color for the user role
100	UserBubbleBG TerminalColor  // background tint behind user message rows
101	UserBubbleFG TerminalColor  // foreground colour for user message rows
102	Assistant    TerminalColor  // label color for the zut role
103	Tool         TerminalColor
104	ToolOut      TerminalColor
105	Error        TerminalColor
106	Warning      TerminalColor
107	Spinner      TerminalColor // spinner frame and activity label
108	ThinkingMax  TerminalColor // status color for the opt-in max reasoning level
109	SelectionBG  TerminalColor // background for highlighted rows
110	SelectionFG  TerminalColor // foreground for highlighted rows
111
112	// UseTerminalPalette resolves TerminalPaletteSlot roles against the
113	// reported palette on truecolor terminals. It has no effect on explicit
114	// Color256 values from a custom theme.
115	UseTerminalPalette bool
116	Terminal           TerminalProfile
117
118	SpinnerFrames     []string
119	SpinnerIntervalMS int
120
121	SyntaxBaseStyle string
122	Syntax          SyntaxTheme
123}
124
125// SyntaxTheme contains the chroma token colors used by code fences,
126// file previews, and diffs. Values are chroma style entries, so they
127// may include attributes after the color (for example "#81a1c1 bold").
128type SyntaxTheme struct {
129	Keyword             string
130	KeywordConstant     string
131	KeywordDeclaration  string
132	KeywordNamespace    string
133	KeywordReserved     string
134	KeywordType         string
135	NameBuiltin         string
136	NameFunction        string
137	NameClass           string
138	NameDecorator       string
139	LiteralString       string
140	LiteralStringEscape string
141	LiteralNumber       string
142	Comment             string
143	CommentPreproc      string
144	Operator            string
145	Punctuation         string
146	Text                string
147}
148
149var nordSyntax = SyntaxTheme{
150	Keyword:             "#81a1c1 bold",
151	KeywordConstant:     "#81a1c1",
152	KeywordDeclaration:  "#81a1c1",
153	KeywordNamespace:    "#81a1c1",
154	KeywordReserved:     "#81a1c1 bold",
155	KeywordType:         "#88c0d0",
156	NameBuiltin:         "#88c0d0",
157	NameFunction:        "#8fbcbb",
158	NameClass:           "#a3be8c bold",
159	NameDecorator:       "#b48ead",
160	LiteralString:       "#a3be8c",
161	LiteralStringEscape: "#bf616a",
162	LiteralNumber:       "#d08770",
163	Comment:             "#616e88 italic",
164	CommentPreproc:      "#b48ead",
165	Operator:            "#eceff4",
166	Punctuation:         "#d8dee9",
167	Text:                "#e5e9f0",
168}
169
170var defaultSpinnerFrames = []string{
171	"⠋",
172	"⠙",
173	"⠚",
174	"⠞",
175	"⠖",
176	"⠦",
177	"⠴",
178	"⠲",
179	"⠳",
180	"⠓",
181}
182
183var Dark = Theme{
184	FG:                Color256(253),
185	Muted:             Color256(244),
186	Accent:            Color256(111),        // soft blue
187	User:              Color256(180),        // warm tan (unused now that the speaker label is gone, kept for skin compat)
188	UserBubbleBG:      ColorRGB(66, 69, 75), // #42454B
189	UserBubbleFG:      Color256(248),        // slightly lighter grey for readability on #42454B
190	Assistant:         Color256(117),        // bright cyan — the zut label color
191	Tool:              Color256(114),        // green
192	ToolOut:           Color256(245),
193	Error:             Color256(203),
194	Warning:           Color256(214),
195	Spinner:           Color256(183), // soft purple
196	ThinkingMax:       Color256(207), // vivid magenta
197	SelectionBG:       Color256(24),  // deep blue background
198	SelectionFG:       Color256(231), // near-white foreground
199	SpinnerFrames:     defaultSpinnerFrames,
200	SpinnerIntervalMS: 80,
201	SyntaxBaseStyle:   "monokai",
202	Syntax:            nordSyntax,
203}
204
205var Light = Theme{
206	FG:                Color256(236),
207	Muted:             Color256(244),
208	Accent:            Color256(33),
209	User:              Color256(94),
210	UserBubbleBG:      Color256(254), // very pale grey panel behind user rows on light theme
211	UserBubbleFG:      Color256(240), // dark grey text, legible on the pale panel
212	Assistant:         Color256(31),  // deep cyan
213	Tool:              Color256(28),
214	ToolOut:           Color256(240),
215	Error:             Color256(160),
216	Warning:           Color256(166),
217	Spinner:           Color256(91),  // purple
218	ThinkingMax:       Color256(127), // deep magenta
219	SelectionBG:       Color256(153), // light blue
220	SelectionFG:       Color256(232), // near-black
221	SpinnerFrames:     defaultSpinnerFrames,
222	SpinnerIntervalMS: 80,
223	SyntaxBaseStyle:   "monokai",
224	Syntax:            nordSyntax,
225}
226
227// FGColor wraps s in a foreground color. The name is intentionally explicit
228// because the color may be xterm-256, ANSI, or RGB.
229func (t Theme) FGColor(c TerminalColor, s string) string {
230	return t.fgPrefix(c) + s + reset
231}
232
233// FG256 retains the historical helper name while accepting the full
234// TerminalColor model.
235//
236// Deprecated: use FGColor.
237func (t Theme) FG256(c TerminalColor, s string) string {
238	return t.FGColor(c, s)
239}
240
241// BG256 retains the historical helper name while accepting the full
242// TerminalColor model.
243//
244// Deprecated: use BG.
245func (t Theme) BG256(c TerminalColor, s string) string {
246	return t.bgPrefix(c) + s + reset
247}
248
249// BG wraps s in a terminal background color. RGB values are quantized when
250// the active terminal does not advertise truecolor support.
251func (t Theme) BG(c TerminalColor, s string) string {
252	return t.bgPrefix(c) + s + reset
253}
254
255func (t Theme) fgPrefix(color TerminalColor) string {
256	return sgrFGColor(t.resolveColor(color))
257}
258
259func (t Theme) bgPrefix(color TerminalColor) string {
260	return sgrBGColor(t.resolveColor(color))
261}
262
263// DimColor fades a theme color toward the terminal background. It is used
264// while resolving adaptive terminal surfaces and by overlay callers.
265func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
266	percent = clampPercent(percent)
267	foreground := t.resolveColor(color)
268	background := t.Terminal.Background
269	if !t.Terminal.HasBackground {
270		background = Color256(0)
271	}
272	fg, _ := rgbForTerminalColor(foreground)
273	bg, _ := rgbForTerminalColor(background)
274	return t.resolveColor(ColorRGB(
275		blendChannel(fg[0], bg[0], percent),
276		blendChannel(fg[1], bg[1], percent),
277		blendChannel(fg[2], bg[2], percent),
278	))
279}
280
281func (t Theme) resolveColor(color TerminalColor) TerminalColor {
282	depth := t.Terminal.ColorDepth()
283	switch color.Mode {
284	case terminalColorDefault:
285		return color
286	case terminalColorPalette:
287		index := clampANSIIndex(color.Index)
288		if depth == ColorDepthTrueColor {
289			if palette, ok := t.Terminal.PaletteColor(index); ok {
290				return palette
291			}
292		}
293		return ColorANSI(ansiIndexForegroundSGR(index))
294	case terminalColor256:
295		index := clampXtermIndex(color.Index)
296		switch depth {
297		case ColorDepthTrueColor:
298			r, g, b := xterm256RGB(index)
299			return ColorRGB(r, g, b)
300		case ColorDepthANSI16:
301			return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(index)))
302		default:
303			return Color256(index)
304		}
305	case terminalColorANSI:
306		if depth == ColorDepthTrueColor {
307			if index, ok := ansiSGRToXtermIndex(color.Index); ok {
308				if palette, ok := t.Terminal.PaletteColor(index); ok {
309					return palette
310				}
311			}
312		}
313	case terminalColorRGB:
314		if depth == ColorDepthANSI16 {
315			return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(nearestXtermColor(color.R, color.G, color.B))))
316		}
317		if depth != ColorDepthTrueColor {
318			return Color256(nearestXtermColor(color.R, color.G, color.B))
319		}
320	}
321	return color
322}
323
324func sgrFGColor(color TerminalColor) string {
325	switch color.Mode {
326	case terminalColorDefault:
327		return "\x1b[39m"
328	case terminalColorANSI:
329		return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
330	case terminalColorRGB:
331		return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
332	default:
333		return sgrFG(clampXtermIndex(color.Index))
334	}
335}
336
337func (t Theme) sgrBGColor(color TerminalColor) string {
338	return sgrBGColor(t.resolveColor(color))
339}
340
341// AccentBar returns a 2-cell-wide leader: a coloured half-block
342// glyph followed by a plain space gutter. Used as the speaker-label
343// prefix in the chat ("▌ you", "▌ zut") and as the editor prompt so
344// the bar reads consistently across the UI.
345func (t Theme) AccentBar(c TerminalColor) string {
346	return t.FGColor(c, "▌ ")
347}
348
349// Highlight paints s with the theme's selection colors (foreground +
350// background). The caller is responsible for padding s to the desired
351// width; styling alone does not extend the background past content.
352func (t Theme) Highlight(s string) string {
353	return t.SelectionStyle() + s + reset
354}
355
356// SelectionStyle returns the SGR prefix for the theme's selected row.
357func (t Theme) SelectionStyle() string {
358	return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
359}
360
361// BackgroundStyle returns the SGR prefix for the optional full-row
362// TUI background. Empty means zut should leave the terminal's
363// configured background untouched.
364func (t Theme) BackgroundStyle() string {
365	if t.Background == nil {
366		return ""
367	}
368	return t.bgPrefix(*t.Background)
369}
370
371// SelectionStyleFG returns the SGR prefix for selected-row text with
372// a custom foreground. Useful for preserving semantic marks inside a
373// highlighted row without hardcoding escape sequences outside theme.go.
374func (t Theme) SelectionStyleFG(fg TerminalColor) string {
375	return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
376}
377
378// PadHighlight styles s and extends the selection background to the
379// full terminal width so the highlight is a full row, not just a
380// rectangle around the text.
381func (t Theme) PadHighlight(s string, width int) string {
382	visible := visibleWidth(s)
383	if visible < width {
384		s += strings.Repeat(" ", width-visible)
385	}
386	return t.SelectionStyle() + s + reset
387}
388
389// UserBubble paints a single user message row with the bubble
390// background colour, padding to width so the tint extends to the
391// full terminal width. Foreground stays in UserBubbleFG so text
392// remains legible against the tint.
393func (t Theme) UserBubble(s string, width int) string {
394	visible := visibleWidth(s)
395	if visible < width {
396		s += strings.Repeat(" ", width-visible)
397	}
398	return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
399}
400
401// UserBubbleRow renders one user-bubble row prefixed with a coloured
402// half-block accent bar ("▌ ") so every line of the bubble has the
403// zut-blue gutter at the very left. The bar lives outside the bubble
404// tint (chat bg) so the bubble itself sits inside it. Width is the
405// outer width including the bar; the bubble content is padded to
406// width-2 (the bar + its trailing space).
407func (t Theme) UserBubbleRow(content string, width int) string {
408	// Bar plus a single space gutter, in the assistant accent colour
409	// so it matches the tool-box / app accent and reads as zut's voice
410	// marker. Two cells wide.
411	bar := t.FGColor(t.Assistant, "▌ ")
412	bubbleW := width - 2
413	if bubbleW < 1 {
414		bubbleW = 1
415	}
416	return bar + t.UserBubble(content, bubbleW)
417}
418
419const (
420	reset           = "\x1b[0m"
421	dim             = "\x1b[2m"
422	normalIntensity = "\x1b[22m"
423)
424
425var dimStyleReplacer = strings.NewReplacer(
426	reset, reset+dim,
427	normalIntensity, normalIntensity+dim,
428)
429
430// Bold wraps s in bold SGR.
431func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
432
433// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
434// independently styled segments remain dimmed too. Like Bold and Italic, it
435// restores only its own attribute so callers can safely compose styles.
436func Dim(s string) string {
437	return dim + dimStyleReplacer.Replace(s) + normalIntensity
438}
439
440// DimLines returns a dimmed copy of lines. It is suitable for content behind
441// a modal layer: the foreground layer can retain its normal styling while
442// every styled segment in the background stays dimmed.
443func DimLines(lines []string) []string {
444	out := make([]string, len(lines))
445	for idx, line := range lines {
446		out[idx] = Dim(line)
447	}
448	return out
449}
450
451// Italic wraps s in italic SGR.
452func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
453
454func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
455func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
456func sgrBGColor(c TerminalColor) string {
457	switch c.Mode {
458	case terminalColorDefault:
459		return "\x1b[49m"
460	case terminalColorANSI:
461		return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
462	case terminalColorRGB:
463		return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
464	default:
465		return sgrBG(c.Index)
466	}
467}
468
469func clampANSIIndex(index int) int {
470	if index < 0 {
471		return 0
472	}
473	if index > 15 {
474		return 15
475	}
476	return index
477}
478
479func ansiIndexForegroundSGR(index int) int {
480	index = clampANSIIndex(index)
481	if index < 8 {
482		return 30 + index
483	}
484	return 90 + index - 8
485}
486
487func nearestANSIColor(index int) int {
488	r, g, b := xterm256RGB(clampXtermIndex(index))
489	best, bestDistance := 0, int(^uint(0)>>1)
490	for slot := 0; slot < 16; slot++ {
491		pr, pg, pb := xterm256RGB(slot)
492		dr, dg, db := r-pr, g-pg, b-pb
493		distance := dr*dr + dg*dg + db*db
494		if distance < bestDistance {
495			best, bestDistance = slot, distance
496		}
497	}
498	return best
499}
500
501func clampXtermIndex(index int) int {
502	if index < 0 {
503		return 0
504	}
505	if index > 255 {
506		return 255
507	}
508	return index
509}
510
511func clampByte(value int) int {
512	if value < 0 {
513		return 0
514	}
515	if value > 255 {
516		return 255
517	}
518	return value
519}
520
521func clampPercent(percent int) int {
522	if percent < 0 {
523		return 0
524	}
525	if percent > 100 {
526		return 100
527	}
528	return percent
529}
530
531func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
532	switch color.Mode {
533	case terminalColorRGB:
534		return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
535	case terminalColor256:
536		r, g, b := xterm256RGB(clampXtermIndex(color.Index))
537		return [3]int{r, g, b}, true
538	case terminalColorPalette:
539		r, g, b := xterm256RGB(clampANSIIndex(color.Index))
540		return [3]int{r, g, b}, true
541	case terminalColorANSI:
542		if index, ok := ansiSGRToXtermIndex(color.Index); ok {
543			r, g, b := xterm256RGB(index)
544			return [3]int{r, g, b}, true
545		}
546	}
547	return [3]int{}, false
548}
549
550func ansiSGRToXtermIndex(sgr int) (int, bool) {
551	switch {
552	case sgr >= 30 && sgr <= 37:
553		return sgr - 30, true
554	case sgr >= 90 && sgr <= 97:
555		return sgr - 90 + 8, true
556	case sgr >= 40 && sgr <= 47:
557		return sgr - 40, true
558	case sgr >= 100 && sgr <= 107:
559		return sgr - 100 + 8, true
560	default:
561		return 0, false
562	}
563}
564
565func ansiForegroundSGR(sgr int) int {
566	switch {
567	case sgr >= 40 && sgr <= 47:
568		return sgr - 10
569	case sgr >= 100 && sgr <= 107:
570		return sgr - 10
571	default:
572		return sgr
573	}
574}
575
576func ansiBackgroundSGR(sgr int) int {
577	switch {
578	case sgr >= 30 && sgr <= 37:
579		return sgr + 10
580	case sgr >= 90 && sgr <= 97:
581		return sgr + 10
582	default:
583		return sgr
584	}
585}
586
587func blendChannel(from, to, percent int) int {
588	return clampByte((from*(100-percent) + to*percent + 50) / 100)
589}
590
591const xtermColorCacheSize = 256
592
593type xtermColorCacheEntry struct {
594	value atomic.Uint64
595}
596
597var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
598
599func nearestXtermColor(r, g, b int) int {
600	r = clampByte(r)
601	g = clampByte(g)
602	b = clampByte(b)
603	key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
604	entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
605	cached := entry.value.Load()
606	keyPrefix := (key + 1) << 8
607	if cached&^uint64(0xff) == keyPrefix {
608		return int(cached & 0xff)
609	}
610
611	best := 0
612	bestDistance := int(^uint(0) >> 1)
613	for index := 0; index < 256; index++ {
614		cr, cg, cb := xterm256RGB(index)
615		dr := r - cr
616		dg := g - cg
617		db := b - cb
618		distance := dr*dr + dg*dg + db*db
619		if distance < bestDistance {
620			best = index
621			bestDistance = distance
622		}
623	}
624	entry.value.Store(keyPrefix | uint64(best))
625	return best
626}
627
628// small itoa to avoid pulling strconv into this hot path twice.
629func itoa(n int) string {
630	if n == 0 {
631		return "0"
632	}
633	buf := [4]byte{}
634	i := len(buf)
635	for n > 0 {
636		i--
637		buf[i] = byte('0' + n%10)
638		n /= 10
639	}
640	return string(buf[i:])
641}

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