v0.11.2 619aabb

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

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