v0.48.1 deaab59

theme_loader.go RawHistoryFolder

1package tui
2
3import (
4	"crypto/sha256"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"sort"
11	"strings"
12	"time"
13
14	"github.com/alecthomas/chroma/v2"
15	"github.com/alecthomas/chroma/v2/styles"
16)
17
18// ThemeOption is one selectable theme discovered under $ZUT_HOME/themes.
19// Value is stored in config.json.
20type ThemeOption struct {
21	Value       string
22	Label       string
23	Description string
24	Path        string
25	Builtin     bool
26}
27
28// ThemeFile is the user-editable JSON shape loaded from
29// $ZUT_HOME/themes/*.json. It carries metadata plus separate overrides
30// for dark and light terminals.
31type ThemeFile struct {
32	Name        string              `json:"name"`
33	Description string              `json:"description"`
34	Colors      ThemeFileColorModes `json:"colors"`
35	Overrides   ThemeOverrides      `json:"-"`
36}
37
38func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
39	type alias ThemeFile
40	var a alias
41	if err := json.Unmarshal(data, &a); err != nil {
42		return err
43	}
44	// Allow a tiny theme file with overrides at the top level, e.g.
45	// {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
46	// Metadata fields are ignored by ThemeOverrides because they do not
47	// have matching json tags.
48	if err := json.Unmarshal(data, &a.Overrides); err != nil {
49		return fmt.Errorf("theme overrides: %w", err)
50	}
51	*tf = ThemeFile(a)
52	return validateThemeFile(*tf)
53}
54
55type ThemeFileColorModes struct {
56	Base     ThemeOverrides `json:"-"`
57	Dark     ThemeOverrides `json:"dark"`
58	Light    ThemeOverrides `json:"light"`
59	HasDark  bool           `json:"-"`
60	HasLight bool           `json:"-"`
61}
62
63func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
64	var raw map[string]json.RawMessage
65	if err := json.Unmarshal(data, &raw); err != nil {
66		return err
67	}
68	if v, ok := raw["dark"]; ok {
69		m.HasDark = true
70		if err := json.Unmarshal(v, &m.Dark); err != nil {
71			return fmt.Errorf("dark overrides: %w", err)
72		}
73	}
74	if v, ok := raw["light"]; ok {
75		m.HasLight = true
76		if err := json.Unmarshal(v, &m.Light); err != nil {
77			return fmt.Errorf("light overrides: %w", err)
78		}
79	}
80	// Also allow colors to directly contain overrides shared by both
81	// modes, e.g. {"colors":{"accent":204}}.
82	var base ThemeOverrides
83	if err := json.Unmarshal(data, &base); err != nil {
84		return fmt.Errorf("shared overrides: %w", err)
85	}
86	m.Base = base
87	return nil
88}
89
90// ThemeOverrides is intentionally pointer-based so a theme file can
91// override only the colors it cares about and inherit the built-in
92// dark/light defaults for everything else.
93type ThemeOverrides struct {
94	FG                *TerminalColorValue  `json:"fg,omitempty"`
95	Muted             *TerminalColorValue  `json:"muted,omitempty"`
96	Accent            *TerminalColorValue  `json:"accent,omitempty"`
97	Background        *TerminalColorValue  `json:"background,omitempty"`
98	User              *TerminalColorValue  `json:"user,omitempty"`
99	UserBubbleBG      *TerminalColorValue  `json:"user_bubble_bg,omitempty"`
100	UserBubbleFG      *TerminalColorValue  `json:"user_bubble_fg,omitempty"`
101	Assistant         *TerminalColorValue  `json:"assistant,omitempty"`
102	Tool              *TerminalColorValue  `json:"tool,omitempty"`
103	ToolOut           *TerminalColorValue  `json:"tool_out,omitempty"`
104	Error             *TerminalColorValue  `json:"error,omitempty"`
105	Warning           *TerminalColorValue  `json:"warning,omitempty"`
106	Spinner           *TerminalColorValue  `json:"spinner,omitempty"`
107	ThinkingMax       *TerminalColorValue  `json:"thinking_max,omitempty"`
108	ThinkingMaxCamel  *TerminalColorValue  `json:"thinkingMax,omitempty"`
109	SelectionBG       *TerminalColorValue  `json:"selection_bg,omitempty"`
110	SelectionFG       *TerminalColorValue  `json:"selection_fg,omitempty"`
111	SpinnerFrames     []string             `json:"spinner_frames,omitempty"`
112	SpinnerIntervalMS *int                 `json:"spinner_interval_ms,omitempty"`
113	SyntaxBaseStyle   *string              `json:"syntax_base_style,omitempty"`
114	Syntax            SyntaxThemeOverrides `json:"syntax,omitempty"`
115}
116
117type SyntaxThemeOverrides struct {
118	Keyword             *string `json:"keyword,omitempty"`
119	KeywordConstant     *string `json:"keyword_constant,omitempty"`
120	KeywordDeclaration  *string `json:"keyword_declaration,omitempty"`
121	KeywordNamespace    *string `json:"keyword_namespace,omitempty"`
122	KeywordReserved     *string `json:"keyword_reserved,omitempty"`
123	KeywordType         *string `json:"keyword_type,omitempty"`
124	NameBuiltin         *string `json:"name_builtin,omitempty"`
125	NameFunction        *string `json:"name_function,omitempty"`
126	NameClass           *string `json:"name_class,omitempty"`
127	NameDecorator       *string `json:"name_decorator,omitempty"`
128	LiteralString       *string `json:"literal_string,omitempty"`
129	LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
130	LiteralNumber       *string `json:"literal_number,omitempty"`
131	Comment             *string `json:"comment,omitempty"`
132	CommentPreproc      *string `json:"comment_preproc,omitempty"`
133	Operator            *string `json:"operator,omitempty"`
134	Punctuation         *string `json:"punctuation,omitempty"`
135	Text                *string `json:"text,omitempty"`
136}
137
138// TerminalColorValue accepts any of these JSON forms:
139//
140//	24                         // xterm-256 color index
141//	"#42454b"                  // RGB hex
142//	{"mode":"ansi","index":100}
143//	{"mode":"rgb","r":66,"g":69,"b":75}
144//	{"mode":"256","index":254}
145type TerminalColorValue struct {
146	TerminalColor
147}
148
149func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
150	var index int
151	if err := json.Unmarshal(data, &index); err == nil {
152		c.TerminalColor = Color256(index)
153		return validateExplicitTerminalColor(c.TerminalColor)
154	}
155	var s string
156	if err := json.Unmarshal(data, &s); err == nil {
157		if rgb, ok := parseHexColor(s); ok {
158			c.TerminalColor = rgb
159			return validateExplicitTerminalColor(c.TerminalColor)
160		}
161		return fmt.Errorf("invalid terminal color %q", s)
162	}
163	var obj struct {
164		Mode  string `json:"mode"`
165		Index int    `json:"index"`
166		R     int    `json:"r"`
167		G     int    `json:"g"`
168		B     int    `json:"b"`
169	}
170	if err := json.Unmarshal(data, &obj); err != nil {
171		return err
172	}
173	switch strings.ToLower(obj.Mode) {
174	case "", "256", "color256", "xterm256":
175		c.TerminalColor = Color256(obj.Index)
176	case "ansi":
177		c.TerminalColor = ColorANSI(obj.Index)
178	case "rgb", "truecolor":
179		c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
180	default:
181		return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
182	}
183	return validateExplicitTerminalColor(c.TerminalColor)
184}
185
186// TerminalTheme constructs zut's adaptive default from terminal-owned
187// defaults and ANSI slots. It never chooses a fixed built-in palette merely
188// because a terminal reports a light background.
189func TerminalTheme(profile TerminalProfile) Theme {
190	t := Theme{
191		FG:                 TerminalDefault(),
192		Muted:              TerminalPaletteSlot(8),
193		Accent:             TerminalPaletteSlot(12),
194		User:               TerminalPaletteSlot(13),
195		UserBubbleBG:       TerminalPaletteSlot(8),
196		UserBubbleFG:       TerminalDefault(),
197		Assistant:          TerminalPaletteSlot(12),
198		Tool:               TerminalPaletteSlot(10),
199		ToolOut:            TerminalPaletteSlot(8),
200		Error:              TerminalPaletteSlot(9),
201		Warning:            TerminalPaletteSlot(11),
202		Spinner:            TerminalPaletteSlot(13),
203		ThinkingMax:        TerminalPaletteSlot(13),
204		SelectionBG:        TerminalPaletteSlot(4),
205		SelectionFG:        TerminalDefault(),
206		UseTerminalPalette: true,
207		Terminal:           profile,
208		SpinnerFrames:      append([]string(nil), defaultSpinnerFrames...),
209		SpinnerIntervalMS:  80,
210		// Terminal syntax is resolved directly from semantic terminal roles.
211		// Keep JSON syntax fields empty until a custom theme explicitly sets one.
212		Syntax: SyntaxTheme{},
213	}
214	if profile.HasForeground && profile.HasBackground {
215		t.Muted = t.DimColor(profile.Foreground, 45)
216		t.ToolOut = t.Muted
217		t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
218		t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
219	}
220	// Auto must leave both the terminal background and cursor color owned by
221	// the terminal. Custom themes can intentionally override Background.
222	t.Background = nil
223	return t
224}
225
226func withTerminalProfile(base, detected Theme) Theme {
227	base.Terminal = detected.Terminal
228	return base
229}
230
231func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
232	fromRGB, _ := rgbForTerminalColor(from)
233	toRGB, _ := rgbForTerminalColor(to)
234	percent = clampPercent(percent)
235	return ColorRGB(
236		blendChannel(fromRGB[0], toRGB[0], percent),
237		blendChannel(fromRGB[1], toRGB[1], percent),
238		blendChannel(fromRGB[2], toRGB[2], percent),
239	)
240}
241
242const maxThemeFileSize = 1 << 20
243
244// ThemeSource is an immutable, fully validated custom theme revision. Runtime
245// profile changes resolve this value without touching the filesystem again.
246type ThemeSource struct {
247	Name   string
248	Path   string
249	Digest [sha256.Size]byte
250	File   ThemeFile
251}
252
253// ThemeResolution is the pure result of resolving a preference against one
254// terminal snapshot and, for custom preferences, one accepted source file.
255type ThemeResolution struct {
256	Theme Theme
257	Name  string
258}
259
260// ThemePreference separates the persisted setting from the process-only
261// ZUT_THEME override. Only dark/light force a running session.
262type ThemePreference struct {
263	Persisted string
264	Effective string
265	Forced    bool
266}
267
268func ResolveThemePreference(persisted, env string) ThemePreference {
269	preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
270	switch strings.ToLower(strings.TrimSpace(env)) {
271	case "dark", "light":
272		preference.Effective = strings.ToLower(strings.TrimSpace(env))
273		preference.Forced = true
274	case "", "auto":
275		// Persisted setting remains effective.
276	}
277	if preference.Effective == "" {
278		preference.Effective = "auto"
279	}
280	return preference
281}
282
283// LoadThemeSource resolves and validates a custom source. Built-in selections
284// have no source and return nil, nil. The file limit prevents a polling reload
285// from allocating an unbounded partial write.
286func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
287	path, err := resolveThemePath(zutHome, preference)
288	if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
289		return nil, err
290	}
291	info, err := os.Stat(path)
292	if err != nil {
293		return nil, fmt.Errorf("theme %s: %w", path, err)
294	}
295	if info.Size() > maxThemeFileSize {
296		return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
297	}
298	b, err := os.ReadFile(path)
299	if err != nil {
300		return nil, fmt.Errorf("read theme %s: %w", path, err)
301	}
302	if len(b) > maxThemeFileSize {
303		return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
304	}
305	var file ThemeFile
306	if err := json.Unmarshal(b, &file); err != nil {
307		return nil, fmt.Errorf("parse theme %s: %w", path, err)
308	}
309	if file.Name == "" {
310		file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
311	}
312	return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
313}
314
315// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
316// so omitted roles continue to follow the controlling terminal.
317func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
318	preference = strings.ToLower(strings.TrimSpace(preference))
319	switch preference {
320	case "", "auto", "default", "system":
321		return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
322	case "dark":
323		return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
324	case "light":
325		return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
326	}
327	if source == nil {
328		return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
329	}
330	base := TerminalTheme(profile)
331	base = applyThemeOverrides(base, source.File.Overrides)
332	base = applyThemeOverrides(base, source.File.Colors.Base)
333	if profileIsLight(profile) {
334		if source.File.Colors.HasLight {
335			base = applyThemeOverrides(base, source.File.Colors.Light)
336		} else if source.File.Colors.HasDark {
337			base = applyThemeOverrides(base, source.File.Colors.Dark)
338		}
339	} else if source.File.Colors.HasDark {
340		base = applyThemeOverrides(base, source.File.Colors.Dark)
341	} else if source.File.Colors.HasLight {
342		base = applyThemeOverrides(base, source.File.Colors.Light)
343	}
344	return ThemeResolution{Theme: base, Name: source.Name}
345}
346
347func profileIsLight(profile TerminalProfile) bool {
348	if profile.SchemeKnown {
349		return profile.Light
350	}
351	if profile.HasBackground {
352		r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
353		return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
354	}
355	return false
356}
357
358// DetectThemeWithCustom performs the initial bounded profile query then uses
359// the same loader and pure resolver as runtime selection.
360func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
361	detected := DetectThemeFromBackground(timeout)
362	return LoadThemeFromHome(zutHome, preferred, detected)
363}
364
365// LoadThemeFromHome is retained as the startup convenience wrapper. New
366// runtime code should keep the ThemeSource and call ResolveTheme directly.
367func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
368	profile := detected.Terminal
369	source, err := LoadThemeSource(zutHome, preferred)
370	if err != nil {
371		return TerminalTheme(profile), "", err
372	}
373	returned := ResolveTheme(preferred, source, profile)
374	return returned.Theme, returned.Name, nil
375}
376
377// AvailableThemes returns built-in and user-installed themes suitable
378// for a settings picker. Invalid JSON files are skipped.
379func AvailableThemes(zutHome string) []ThemeOption {
380	out := []ThemeOption{
381		{Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true},
382		{Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
383		{Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
384	}
385	seen := map[string]bool{"auto": true, "dark": true, "light": true}
386	paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
387	sort.Strings(paths)
388	for _, path := range paths {
389		b, err := os.ReadFile(path)
390		if err != nil {
391			continue
392		}
393		var tf ThemeFile
394		if err := json.Unmarshal(b, &tf); err != nil {
395			continue
396		}
397		value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
398		if value == "" || seen[value] {
399			continue
400		}
401		desc := tf.Description
402		if desc == "" {
403			desc = path
404		}
405		out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
406		seen[value] = true
407	}
408	return out
409}
410
411// ThemeOptionFromFile parses one theme JSON file for picker display.
412// value is what will be stored in config; pass an absolute path for
413// extension-owned themes so they can be loaded without copying into
414// $ZUT_HOME/themes.
415func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
416	b, err := os.ReadFile(path)
417	if err != nil {
418		return ThemeOption{}, false
419	}
420	var tf ThemeFile
421	if err := json.Unmarshal(b, &tf); err != nil {
422		return ThemeOption{}, false
423	}
424	if value == "" {
425		value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
426	}
427	label := value
428	if tf.Name != "" {
429		label = tf.Name
430	}
431	desc := tf.Description
432	if source != "" {
433		if desc != "" {
434			desc = "from " + source + " — " + desc
435		} else {
436			desc = "from " + source
437		}
438	}
439	if desc == "" {
440		desc = path
441	}
442	return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
443}
444
445func ThemeExists(zutHome, preferred string) bool {
446	path, err := resolveThemePath(zutHome, preferred)
447	return err == nil && path != ""
448}
449
450func resolveThemePath(zutHome, preferred string) (string, error) {
451	preferred = strings.TrimSpace(preferred)
452	switch strings.ToLower(preferred) {
453	case "", "auto", "default", "system":
454		return "", nil
455	case "dark":
456		return "builtin:dark", nil
457	case "light":
458		return "builtin:light", nil
459	}
460	if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
461		candidates := []string{preferred}
462		if filepath.Ext(preferred) == "" {
463			candidates = append(candidates,
464				filepath.Join(zutHome, "themes", preferred+".json"),
465			)
466		} else if !filepath.IsAbs(preferred) {
467			candidates = append(candidates,
468				filepath.Join(zutHome, "themes", preferred),
469			)
470		}
471		for _, c := range candidates {
472			if _, err := os.Stat(c); err == nil {
473				return c, nil
474			}
475		}
476		return "", fmt.Errorf("theme %q not found", preferred)
477	}
478
479	return "", nil
480}
481
482func themeFilesIn(dir string) ([]string, error) {
483	entries, err := os.ReadDir(dir)
484	if errors.Is(err, os.ErrNotExist) {
485		return nil, nil
486	}
487	if err != nil {
488		return nil, err
489	}
490	var out []string
491	for _, e := range entries {
492		if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
493			continue
494		}
495		out = append(out, filepath.Join(dir, e.Name()))
496	}
497	return out, nil
498}
499
500func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
501	if o.FG != nil {
502		th.FG = o.FG.TerminalColor
503	}
504	if o.Muted != nil {
505		th.Muted = o.Muted.TerminalColor
506	}
507	if o.Accent != nil {
508		th.Accent = o.Accent.TerminalColor
509	}
510	if o.Background != nil {
511		bg := o.Background.TerminalColor
512		th.Background = &bg
513	}
514	if o.User != nil {
515		th.User = o.User.TerminalColor
516	}
517	if o.UserBubbleBG != nil {
518		th.UserBubbleBG = o.UserBubbleBG.TerminalColor
519	}
520	if o.UserBubbleFG != nil {
521		th.UserBubbleFG = o.UserBubbleFG.TerminalColor
522	}
523	if o.Assistant != nil {
524		th.Assistant = o.Assistant.TerminalColor
525	}
526	if o.Tool != nil {
527		th.Tool = o.Tool.TerminalColor
528	}
529	if o.ToolOut != nil {
530		th.ToolOut = o.ToolOut.TerminalColor
531	}
532	if o.Error != nil {
533		th.Error = o.Error.TerminalColor
534	}
535	if o.Warning != nil {
536		th.Warning = o.Warning.TerminalColor
537	}
538	if o.Spinner != nil {
539		th.Spinner = o.Spinner.TerminalColor
540	}
541	if o.ThinkingMax != nil {
542		th.ThinkingMax = o.ThinkingMax.TerminalColor
543	} else if o.ThinkingMaxCamel != nil {
544		th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
545	}
546	if o.SelectionBG != nil {
547		th.SelectionBG = o.SelectionBG.TerminalColor
548	}
549	if o.SelectionFG != nil {
550		th.SelectionFG = o.SelectionFG.TerminalColor
551	}
552	if len(o.SpinnerFrames) > 0 {
553		th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
554	}
555	if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
556		th.SpinnerIntervalMS = *o.SpinnerIntervalMS
557	}
558	if o.SyntaxBaseStyle != nil {
559		th.SyntaxBaseStyle = *o.SyntaxBaseStyle
560	}
561	th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
562	return th
563}
564
565func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
566	if o.Keyword != nil {
567		s.Keyword = *o.Keyword
568	}
569	if o.KeywordConstant != nil {
570		s.KeywordConstant = *o.KeywordConstant
571	}
572	if o.KeywordDeclaration != nil {
573		s.KeywordDeclaration = *o.KeywordDeclaration
574	}
575	if o.KeywordNamespace != nil {
576		s.KeywordNamespace = *o.KeywordNamespace
577	}
578	if o.KeywordReserved != nil {
579		s.KeywordReserved = *o.KeywordReserved
580	}
581	if o.KeywordType != nil {
582		s.KeywordType = *o.KeywordType
583	}
584	if o.NameBuiltin != nil {
585		s.NameBuiltin = *o.NameBuiltin
586	}
587	if o.NameFunction != nil {
588		s.NameFunction = *o.NameFunction
589	}
590	if o.NameClass != nil {
591		s.NameClass = *o.NameClass
592	}
593	if o.NameDecorator != nil {
594		s.NameDecorator = *o.NameDecorator
595	}
596	if o.LiteralString != nil {
597		s.LiteralString = *o.LiteralString
598	}
599	if o.LiteralStringEscape != nil {
600		s.LiteralStringEscape = *o.LiteralStringEscape
601	}
602	if o.LiteralNumber != nil {
603		s.LiteralNumber = *o.LiteralNumber
604	}
605	if o.Comment != nil {
606		s.Comment = *o.Comment
607	}
608	if o.CommentPreproc != nil {
609		s.CommentPreproc = *o.CommentPreproc
610	}
611	if o.Operator != nil {
612		s.Operator = *o.Operator
613	}
614	if o.Punctuation != nil {
615		s.Punctuation = *o.Punctuation
616	}
617	if o.Text != nil {
618		s.Text = *o.Text
619	}
620	return s
621}
622
623func validateExplicitTerminalColor(color TerminalColor) error {
624	switch color.Mode {
625	case terminalColor256:
626		if color.Index < 0 || color.Index > 255 {
627			return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
628		}
629	case terminalColorANSI:
630		if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
631			return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
632		}
633	case terminalColorRGB:
634		if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
635			return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
636		}
637	default:
638		return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
639	}
640	return nil
641}
642
643func validateThemeFile(file ThemeFile) error {
644	for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
645		if err := validateThemeOverrides(overrides); err != nil {
646			return err
647		}
648	}
649	return nil
650}
651
652func validateThemeOverrides(o ThemeOverrides) error {
653	for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} {
654		if color != nil {
655			if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
656				return err
657			}
658		}
659	}
660	if len(o.SpinnerFrames) > 64 {
661		return errors.New("spinner_frames has more than 64 entries")
662	}
663	for _, frame := range o.SpinnerFrames {
664		if frame == "" || len([]rune(frame)) > 16 {
665			return fmt.Errorf("invalid spinner frame %q", frame)
666		}
667	}
668	if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
669		return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
670	}
671	if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
672		if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
673			return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
674		}
675	}
676	for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} {
677		if entry == nil {
678			continue
679		}
680		if _, err := chroma.ParseStyleEntry(*entry); err != nil {
681			return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
682		}
683	}
684	return nil
685}
686
687func parseHexColor(s string) (TerminalColor, bool) {
688	s = strings.TrimPrefix(strings.TrimSpace(s), "#")
689	if len(s) != 6 {
690		return TerminalColor{}, false
691	}
692	var rgb [3]int
693	for i := 0; i < 3; i++ {
694		v, ok := parseHexByte(s[i*2 : i*2+2])
695		if !ok {
696			return TerminalColor{}, false
697		}
698		rgb[i] = v
699	}
700	return ColorRGB(rgb[0], rgb[1], rgb[2]), true
701}
702
703func parseHexByte(s string) (int, bool) {
704	var n int
705	for _, r := range s {
706		n *= 16
707		switch {
708		case r >= '0' && r <= '9':
709			n += int(r - '0')
710		case r >= 'a' && r <= 'f':
711			n += int(r-'a') + 10
712		case r >= 'A' && r <= 'F':
713			n += int(r-'A') + 10
714		default:
715			return 0, false
716		}
717	}
718	return n, true
719}

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