v0.1.1 7141aff

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 + funny working line
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	SpinnerMessages   []string
90	SpinnerIntervalMS int
91
92	SyntaxBaseStyle string
93	Syntax          SyntaxTheme
94}
95
96// SyntaxTheme contains the chroma token colors used by code fences,
97// file previews, and diffs. Values are chroma style entries, so they
98// may include attributes after the color (for example "#81a1c1 bold").
99type SyntaxTheme struct {
100	Keyword             string
101	KeywordConstant     string
102	KeywordDeclaration  string
103	KeywordNamespace    string
104	KeywordReserved     string
105	KeywordType         string
106	NameBuiltin         string
107	NameFunction        string
108	NameClass           string
109	NameDecorator       string
110	LiteralString       string
111	LiteralStringEscape string
112	LiteralNumber       string
113	Comment             string
114	CommentPreproc      string
115	Operator            string
116	Punctuation         string
117	Text                string
118}
119
120var nordSyntax = SyntaxTheme{
121	Keyword:             "#81a1c1 bold",
122	KeywordConstant:     "#81a1c1",
123	KeywordDeclaration:  "#81a1c1",
124	KeywordNamespace:    "#81a1c1",
125	KeywordReserved:     "#81a1c1 bold",
126	KeywordType:         "#88c0d0",
127	NameBuiltin:         "#88c0d0",
128	NameFunction:        "#8fbcbb",
129	NameClass:           "#a3be8c bold",
130	NameDecorator:       "#b48ead",
131	LiteralString:       "#a3be8c",
132	LiteralStringEscape: "#bf616a",
133	LiteralNumber:       "#d08770",
134	Comment:             "#616e88 italic",
135	CommentPreproc:      "#b48ead",
136	Operator:            "#eceff4",
137	Punctuation:         "#d8dee9",
138	Text:                "#e5e9f0",
139}
140
141var defaultSpinnerFrames = []string{
142	"⠋",
143	"⠙",
144	"⠚",
145	"⠞",
146	"⠖",
147	"⠦",
148	"⠴",
149	"⠲",
150	"⠳",
151	"⠓",
152}
153
154var defaultSpinnerMessages = []string{
155	"thinking",
156	"reticulating splines",
157	"bribing the tokenizer",
158	"asking the rubber duck",
159	"summoning daemons",
160	"consulting the oracle",
161	"herding tokens",
162	"compiling excuses",
163	"poking the model",
164	"negotiating with rate limits",
165	"picking a fight with syntax",
166	"reading between the bits",
167	"tasting the semicolons",
168	"pretending to understand the code",
169	"petting the cache",
170	"drafting clever replies",
171	"warming up the GPU choir",
172	"arguing with a stack trace",
173	"googling the answer (not really)",
174	"rewriting history",
175	"every draft is a stone in the work",
176	"bringing order to the unhewn",
177	"finding the load-bearing measure",
178	"where clarity grows, work grows lighter",
179	"every correction serves the work",
180}
181
182var Dark = Theme{
183	FG:                Color256(253),
184	Muted:             Color256(244),
185	Accent:            Color256(111),        // soft blue
186	User:              Color256(180),        // warm tan (unused now that the speaker label is gone, kept for skin compat)
187	UserBubbleBG:      ColorRGB(66, 69, 75), // #42454B
188	UserBubbleFG:      Color256(248),        // slightly lighter grey for readability on #42454B
189	Assistant:         Color256(117),        // bright cyan — the zut label color
190	Tool:              Color256(114),        // green
191	ToolOut:           Color256(245),
192	Error:             Color256(203),
193	Warning:           Color256(214),
194	Spinner:           Color256(183), // soft purple
195	ThinkingMax:       Color256(207), // vivid magenta
196	SelectionBG:       Color256(24),  // deep blue background
197	SelectionFG:       Color256(231), // near-white foreground
198	SpinnerFrames:     defaultSpinnerFrames,
199	SpinnerMessages:   defaultSpinnerMessages,
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	SpinnerMessages:   defaultSpinnerMessages,
223	SpinnerIntervalMS: 80,
224	SyntaxBaseStyle:   "monokai",
225	Syntax:            nordSyntax,
226}
227
228// FGColor wraps s in a foreground color. The name is intentionally explicit
229// because the color may be xterm-256, ANSI, or RGB.
230func (t Theme) FGColor(c TerminalColor, s string) string {
231	return t.fgPrefix(c) + s + reset
232}
233
234// FG256 retains the historical helper name while accepting the full
235// TerminalColor model.
236//
237// Deprecated: use FGColor.
238func (t Theme) FG256(c TerminalColor, s string) string {
239	return t.FGColor(c, s)
240}
241
242// BG256 retains the historical helper name while accepting the full
243// TerminalColor model.
244//
245// Deprecated: use BG.
246func (t Theme) BG256(c TerminalColor, s string) string {
247	return t.bgPrefix(c) + s + reset
248}
249
250// BG wraps s in a terminal background color. RGB values are quantized when
251// the active terminal does not advertise truecolor support.
252func (t Theme) BG(c TerminalColor, s string) string {
253	return t.bgPrefix(c) + s + reset
254}
255
256func (t Theme) fgPrefix(color TerminalColor) string {
257	return sgrFGColor(t.resolveColor(color))
258}
259
260func (t Theme) bgPrefix(color TerminalColor) string {
261	return sgrBGColor(t.resolveColor(color))
262}
263
264// DimColor fades a theme color toward the terminal background. It is used
265// by inherited theme construction and is also available to callers that need
266// terminal-aware dimming for overlays.
267func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
268	percent = clampPercent(percent)
269	foreground := t.resolveColor(color)
270	background := t.Terminal.Background
271	if !t.Terminal.HasBackground {
272		background = Color256(0)
273	}
274	fg, _ := rgbForTerminalColor(foreground)
275	bg, _ := rgbForTerminalColor(background)
276	return t.resolveColor(ColorRGB(
277		blendChannel(fg[0], bg[0], percent),
278		blendChannel(fg[1], bg[1], percent),
279		blendChannel(fg[2], bg[2], percent),
280	))
281}
282
283func (t Theme) resolveColor(color TerminalColor) TerminalColor {
284	switch color.Mode {
285	case terminalColor256:
286		index := clampXtermIndex(color.Index)
287		if t.Terminal.TrueColor {
288			if t.Inherited {
289				if palette, ok := t.Terminal.PaletteColor(index); ok {
290					return palette
291				}
292			}
293			r, g, b := xterm256RGB(index)
294			return ColorRGB(r, g, b)
295		}
296		return Color256(index)
297	case terminalColorANSI:
298		if t.Inherited && t.Terminal.TrueColor {
299			if index, ok := ansiSGRToXtermIndex(color.Index); ok {
300				if palette, ok := t.Terminal.PaletteColor(index); ok {
301					return palette
302				}
303				r, g, b := xterm256RGB(index)
304				return ColorRGB(r, g, b)
305			}
306		}
307	case terminalColorRGB:
308		if !t.Terminal.TrueColor {
309			return Color256(nearestXtermColor(color.R, color.G, color.B))
310		}
311	}
312	return color
313}
314
315func sgrFGColor(color TerminalColor) string {
316	switch color.Mode {
317	case terminalColorANSI:
318		return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
319	case terminalColorRGB:
320		return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
321	default:
322		return sgrFG(clampXtermIndex(color.Index))
323	}
324}
325
326func (t Theme) sgrBGColor(color TerminalColor) string {
327	return sgrBGColor(t.resolveColor(color))
328}
329
330// AccentBar returns a 2-cell-wide leader: a coloured half-block
331// glyph followed by a plain space gutter. Used as the speaker-label
332// prefix in the chat ("▌ you", "▌ zut") and as the editor prompt so
333// the bar reads consistently across the UI.
334func (t Theme) AccentBar(c TerminalColor) string {
335	return t.FGColor(c, "▌ ")
336}
337
338// Highlight paints s with the theme's selection colors (foreground +
339// background). The caller is responsible for padding s to the desired
340// width; styling alone does not extend the background past content.
341func (t Theme) Highlight(s string) string {
342	return t.SelectionStyle() + s + reset
343}
344
345// SelectionStyle returns the SGR prefix for the theme's selected row.
346func (t Theme) SelectionStyle() string {
347	return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
348}
349
350// BackgroundStyle returns the SGR prefix for the optional full-row
351// TUI background. Empty means zut should leave the terminal's
352// configured background untouched.
353func (t Theme) BackgroundStyle() string {
354	if t.Background == nil {
355		return ""
356	}
357	return t.bgPrefix(*t.Background)
358}
359
360// SelectionStyleFG returns the SGR prefix for selected-row text with
361// a custom foreground. Useful for preserving semantic marks inside a
362// highlighted row without hardcoding escape sequences outside theme.go.
363func (t Theme) SelectionStyleFG(fg TerminalColor) string {
364	return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
365}
366
367// PadHighlight styles s and extends the selection background to the
368// full terminal width so the highlight is a full row, not just a
369// rectangle around the text.
370func (t Theme) PadHighlight(s string, width int) string {
371	visible := visibleWidth(s)
372	if visible < width {
373		s += strings.Repeat(" ", width-visible)
374	}
375	return t.SelectionStyle() + s + reset
376}
377
378// UserBubble paints a single user message row with the bubble
379// background colour, padding to width so the tint extends to the
380// full terminal width. Foreground stays in UserBubbleFG so text
381// remains legible against the tint.
382func (t Theme) UserBubble(s string, width int) string {
383	visible := visibleWidth(s)
384	if visible < width {
385		s += strings.Repeat(" ", width-visible)
386	}
387	return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
388}
389
390// UserBubbleRow renders one user-bubble row prefixed with a coloured
391// half-block accent bar ("▌ ") so every line of the bubble has the
392// zut-blue gutter at the very left. The bar lives outside the bubble
393// tint (chat bg) so the bubble itself sits inside it. Width is the
394// outer width including the bar; the bubble content is padded to
395// width-2 (the bar + its trailing space).
396func (t Theme) UserBubbleRow(content string, width int) string {
397	// Bar plus a single space gutter, in the assistant accent colour
398	// so it matches the tool-box / app accent and reads as zut's voice
399	// marker. Two cells wide.
400	bar := t.FGColor(t.Assistant, "▌ ")
401	bubbleW := width - 2
402	if bubbleW < 1 {
403		bubbleW = 1
404	}
405	return bar + t.UserBubble(content, bubbleW)
406}
407
408// Bold wraps s in bold SGR.
409func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
410
411// Dim wraps s in dim SGR.
412func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
413
414// Italic wraps s in italic SGR.
415func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
416
417const reset = "\x1b[0m"
418
419func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
420func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
421func sgrBGColor(c TerminalColor) string {
422	switch c.Mode {
423	case terminalColorANSI:
424		return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
425	case terminalColorRGB:
426		return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
427	default:
428		return sgrBG(c.Index)
429	}
430}
431
432func clampXtermIndex(index int) int {
433	if index < 0 {
434		return 0
435	}
436	if index > 255 {
437		return 255
438	}
439	return index
440}
441
442func clampByte(value int) int {
443	if value < 0 {
444		return 0
445	}
446	if value > 255 {
447		return 255
448	}
449	return value
450}
451
452func clampPercent(percent int) int {
453	if percent < 0 {
454		return 0
455	}
456	if percent > 100 {
457		return 100
458	}
459	return percent
460}
461
462func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
463	switch color.Mode {
464	case terminalColorRGB:
465		return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
466	case terminalColor256:
467		r, g, b := xterm256RGB(clampXtermIndex(color.Index))
468		return [3]int{r, g, b}, true
469	case terminalColorANSI:
470		if index, ok := ansiSGRToXtermIndex(color.Index); ok {
471			r, g, b := xterm256RGB(index)
472			return [3]int{r, g, b}, true
473		}
474	}
475	return [3]int{}, false
476}
477
478func ansiSGRToXtermIndex(sgr int) (int, bool) {
479	switch {
480	case sgr >= 30 && sgr <= 37:
481		return sgr - 30, true
482	case sgr >= 90 && sgr <= 97:
483		return sgr - 90 + 8, true
484	case sgr >= 40 && sgr <= 47:
485		return sgr - 40, true
486	case sgr >= 100 && sgr <= 107:
487		return sgr - 100 + 8, true
488	default:
489		return 0, false
490	}
491}
492
493func ansiForegroundSGR(sgr int) int {
494	switch {
495	case sgr >= 40 && sgr <= 47:
496		return sgr - 10
497	case sgr >= 100 && sgr <= 107:
498		return sgr - 10
499	default:
500		return sgr
501	}
502}
503
504func ansiBackgroundSGR(sgr int) int {
505	switch {
506	case sgr >= 30 && sgr <= 37:
507		return sgr + 10
508	case sgr >= 90 && sgr <= 97:
509		return sgr + 10
510	default:
511		return sgr
512	}
513}
514
515func blendChannel(from, to, percent int) int {
516	return clampByte((from*(100-percent) + to*percent + 50) / 100)
517}
518
519const xtermColorCacheSize = 256
520
521type xtermColorCacheEntry struct {
522	value atomic.Uint64
523}
524
525var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
526
527func nearestXtermColor(r, g, b int) int {
528	r = clampByte(r)
529	g = clampByte(g)
530	b = clampByte(b)
531	key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
532	entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
533	cached := entry.value.Load()
534	keyPrefix := (key + 1) << 8
535	if cached&^uint64(0xff) == keyPrefix {
536		return int(cached & 0xff)
537	}
538
539	best := 0
540	bestDistance := int(^uint(0) >> 1)
541	for index := 0; index < 256; index++ {
542		cr, cg, cb := xterm256RGB(index)
543		dr := r - cr
544		dg := g - cg
545		db := b - cb
546		distance := dr*dr + dg*dg + db*db
547		if distance < bestDistance {
548			best = index
549			bestDistance = distance
550		}
551	}
552	entry.value.Store(keyPrefix | uint64(best))
553	return best
554}
555
556// small itoa to avoid pulling strconv into this hot path twice.
557func itoa(n int) string {
558	if n == 0 {
559		return "0"
560	}
561	buf := [4]byte{}
562	i := len(buf)
563	for n > 0 {
564		i--
565		buf[i] = byte('0' + n%10)
566		n /= 10
567	}
568	return string(buf[i:])
569}

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