v0.2.0 42a5079

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
408const (
409	reset           = "\x1b[0m"
410	dim             = "\x1b[2m"
411	normalIntensity = "\x1b[22m"
412)
413
414var dimStyleReplacer = strings.NewReplacer(
415	reset, reset+dim,
416	normalIntensity, normalIntensity+dim,
417)
418
419// Bold wraps s in bold SGR.
420func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
421
422// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
423// independently styled segments remain dimmed too. Like Bold and Italic, it
424// restores only its own attribute so callers can safely compose styles.
425func Dim(s string) string {
426	return dim + dimStyleReplacer.Replace(s) + normalIntensity
427}
428
429// DimLines returns a dimmed copy of lines. It is suitable for content behind
430// a modal layer: the foreground layer can retain its normal styling while
431// every styled segment in the background stays dimmed.
432func DimLines(lines []string) []string {
433	out := make([]string, len(lines))
434	for idx, line := range lines {
435		out[idx] = Dim(line)
436	}
437	return out
438}
439
440// Italic wraps s in italic SGR.
441func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
442
443func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
444func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
445func sgrBGColor(c TerminalColor) string {
446	switch c.Mode {
447	case terminalColorANSI:
448		return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
449	case terminalColorRGB:
450		return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
451	default:
452		return sgrBG(c.Index)
453	}
454}
455
456func clampXtermIndex(index int) int {
457	if index < 0 {
458		return 0
459	}
460	if index > 255 {
461		return 255
462	}
463	return index
464}
465
466func clampByte(value int) int {
467	if value < 0 {
468		return 0
469	}
470	if value > 255 {
471		return 255
472	}
473	return value
474}
475
476func clampPercent(percent int) int {
477	if percent < 0 {
478		return 0
479	}
480	if percent > 100 {
481		return 100
482	}
483	return percent
484}
485
486func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
487	switch color.Mode {
488	case terminalColorRGB:
489		return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
490	case terminalColor256:
491		r, g, b := xterm256RGB(clampXtermIndex(color.Index))
492		return [3]int{r, g, b}, true
493	case terminalColorANSI:
494		if index, ok := ansiSGRToXtermIndex(color.Index); ok {
495			r, g, b := xterm256RGB(index)
496			return [3]int{r, g, b}, true
497		}
498	}
499	return [3]int{}, false
500}
501
502func ansiSGRToXtermIndex(sgr int) (int, bool) {
503	switch {
504	case sgr >= 30 && sgr <= 37:
505		return sgr - 30, true
506	case sgr >= 90 && sgr <= 97:
507		return sgr - 90 + 8, true
508	case sgr >= 40 && sgr <= 47:
509		return sgr - 40, true
510	case sgr >= 100 && sgr <= 107:
511		return sgr - 100 + 8, true
512	default:
513		return 0, false
514	}
515}
516
517func ansiForegroundSGR(sgr int) int {
518	switch {
519	case sgr >= 40 && sgr <= 47:
520		return sgr - 10
521	case sgr >= 100 && sgr <= 107:
522		return sgr - 10
523	default:
524		return sgr
525	}
526}
527
528func ansiBackgroundSGR(sgr int) int {
529	switch {
530	case sgr >= 30 && sgr <= 37:
531		return sgr + 10
532	case sgr >= 90 && sgr <= 97:
533		return sgr + 10
534	default:
535		return sgr
536	}
537}
538
539func blendChannel(from, to, percent int) int {
540	return clampByte((from*(100-percent) + to*percent + 50) / 100)
541}
542
543const xtermColorCacheSize = 256
544
545type xtermColorCacheEntry struct {
546	value atomic.Uint64
547}
548
549var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
550
551func nearestXtermColor(r, g, b int) int {
552	r = clampByte(r)
553	g = clampByte(g)
554	b = clampByte(b)
555	key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
556	entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
557	cached := entry.value.Load()
558	keyPrefix := (key + 1) << 8
559	if cached&^uint64(0xff) == keyPrefix {
560		return int(cached & 0xff)
561	}
562
563	best := 0
564	bestDistance := int(^uint(0) >> 1)
565	for index := 0; index < 256; index++ {
566		cr, cg, cb := xterm256RGB(index)
567		dr := r - cr
568		dg := g - cg
569		db := b - cb
570		distance := dr*dr + dg*dg + db*db
571		if distance < bestDistance {
572			best = index
573			bestDistance = distance
574		}
575	}
576	entry.value.Store(keyPrefix | uint64(best))
577	return best
578}
579
580// small itoa to avoid pulling strconv into this hot path twice.
581func itoa(n int) string {
582	if n == 0 {
583		return "0"
584	}
585	buf := [4]byte{}
586	i := len(buf)
587	for n > 0 {
588		i--
589		buf[i] = byte('0' + n%10)
590		n /= 10
591	}
592	return string(buf[i:])
593}

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 477

↑↓ moveenter openesc close