v0.2.16 a7ef8c2

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 "strings"
7
8// TerminalColor describes a terminal colour in one of the colour
9// spaces terminals commonly support. Most of zot's theme still uses
10// xterm-256 indexes, but user-bubble backgrounds can also use ANSI
11// theme slots (for example SGR 100 / bright-black background) so they
12// match the user's terminal theme rather than the fixed 256-colour
13// cube.
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// ANSI 256-color palette used by zot. Defined as numeric codes so we
37// can swap themes without changing any render code.
38type Theme struct {
39	FG           int
40	Muted        int
41	Accent       int
42	Background   *TerminalColor // optional full-row TUI background; nil keeps terminal default
43	User         int            // label color for the user role
44	UserBubbleBG TerminalColor  // background tint behind user message rows
45	UserBubbleFG int            // foreground colour for user message rows
46	Assistant    int            // label color for the zot role
47	Tool         int
48	ToolOut      int
49	Error        int
50	Warning      int
51	Spinner      int // spinner + funny working line
52	SelectionBG  int // background for highlighted rows
53	SelectionFG  int // foreground for highlighted rows
54
55	SpinnerFrames     []string
56	SpinnerMessages   []string
57	SpinnerIntervalMS int
58
59	SyntaxBaseStyle string
60	Syntax          SyntaxTheme
61}
62
63// SyntaxTheme contains the chroma token colors used by code fences,
64// file previews, and diffs. Values are chroma style entries, so they
65// may include attributes after the color (for example "#81a1c1 bold").
66type SyntaxTheme struct {
67	Keyword             string
68	KeywordConstant     string
69	KeywordDeclaration  string
70	KeywordNamespace    string
71	KeywordReserved     string
72	KeywordType         string
73	NameBuiltin         string
74	NameFunction        string
75	NameClass           string
76	NameDecorator       string
77	LiteralString       string
78	LiteralStringEscape string
79	LiteralNumber       string
80	Comment             string
81	CommentPreproc      string
82	Operator            string
83	Punctuation         string
84	Text                string
85}
86
87var nordSyntax = SyntaxTheme{
88	Keyword:             "#81a1c1 bold",
89	KeywordConstant:     "#81a1c1",
90	KeywordDeclaration:  "#81a1c1",
91	KeywordNamespace:    "#81a1c1",
92	KeywordReserved:     "#81a1c1 bold",
93	KeywordType:         "#88c0d0",
94	NameBuiltin:         "#88c0d0",
95	NameFunction:        "#8fbcbb",
96	NameClass:           "#a3be8c bold",
97	NameDecorator:       "#b48ead",
98	LiteralString:       "#a3be8c",
99	LiteralStringEscape: "#bf616a",
100	LiteralNumber:       "#d08770",
101	Comment:             "#616e88 italic",
102	CommentPreproc:      "#b48ead",
103	Operator:            "#eceff4",
104	Punctuation:         "#d8dee9",
105	Text:                "#e5e9f0",
106}
107
108var defaultSpinnerFrames = []string{
109	"⠋",
110	"⠙",
111	"⠚",
112	"⠞",
113	"⠖",
114	"⠦",
115	"⠴",
116	"⠲",
117	"⠳",
118	"⠓",
119}
120
121var defaultSpinnerMessages = []string{
122	"thinking",
123	"reticulating splines",
124	"bribing the tokenizer",
125	"asking the rubber duck",
126	"summoning daemons",
127	"consulting the oracle",
128	"herding tokens",
129	"compiling excuses",
130	"poking the model",
131	"negotiating with rate limits",
132	"picking a fight with syntax",
133	"reading between the bits",
134	"tasting the semicolons",
135	"pretending to understand the code",
136	"petting the cache",
137	"drafting clever replies",
138	"warming up the GPU choir",
139	"arguing with a stack trace",
140	"googling the answer (not really)",
141	"rewriting history",
142	"every draft is a stone in the work",
143	"bringing order to the unhewn",
144	"finding the load-bearing measure",
145	"where clarity grows, work grows lighter",
146	"every correction serves the work",
147}
148
149var Dark = Theme{
150	FG:                253,
151	Muted:             244,
152	Accent:            111,                  // soft blue
153	User:              180,                  // warm tan (unused now that the speaker label is gone, kept for skin compat)
154	UserBubbleBG:      ColorRGB(66, 69, 75), // #42454B
155	UserBubbleFG:      248,                  // slightly lighter grey for readability on #42454B
156	Assistant:         117,                  // bright cyan — the zot label color
157	Tool:              114,                  // green
158	ToolOut:           245,
159	Error:             203,
160	Warning:           214,
161	Spinner:           183, // soft purple
162	SelectionBG:       24,  // deep blue background
163	SelectionFG:       231, // near-white foreground
164	SpinnerFrames:     defaultSpinnerFrames,
165	SpinnerMessages:   defaultSpinnerMessages,
166	SpinnerIntervalMS: 80,
167	SyntaxBaseStyle:   "monokai",
168	Syntax:            nordSyntax,
169}
170
171var Light = Theme{
172	FG:                236,
173	Muted:             244,
174	Accent:            33,
175	User:              94,
176	UserBubbleBG:      Color256(254), // very pale grey panel behind user rows on light theme
177	UserBubbleFG:      240,           // dark grey text, legible on the pale panel
178	Assistant:         31,            // deep cyan
179	Tool:              28,
180	ToolOut:           240,
181	Error:             160,
182	Warning:           166,
183	Spinner:           91,  // purple
184	SelectionBG:       153, // light blue
185	SelectionFG:       232, // near-black
186	SpinnerFrames:     defaultSpinnerFrames,
187	SpinnerMessages:   defaultSpinnerMessages,
188	SpinnerIntervalMS: 80,
189	SyntaxBaseStyle:   "monokai",
190	Syntax:            nordSyntax,
191}
192
193// FG256 wraps s in foreground color c using ANSI 256-color SGR.
194func (t Theme) FG256(c int, s string) string {
195	return sgrFG(c) + s + reset
196}
197
198// BG256 wraps s in background color c using ANSI 256-color SGR.
199// Useful when the visible cell needs a coloured fill but the
200// underlying character should be a regular space (so mouse-copy
201// from the terminal yields whitespace instead of a glyph).
202func (t Theme) BG256(c int, s string) string {
203	return sgrBG(c) + s + reset
204}
205
206// BG wraps s in a terminal background colour. Unlike BG256, this can
207// target ANSI theme slots or truecolor RGB, which lets selected UI
208// surfaces follow the user's terminal theme.
209func (t Theme) BG(c TerminalColor, s string) string {
210	return sgrBGColor(c) + s + reset
211}
212
213// AccentBar returns a 2-cell-wide leader: a coloured half-block
214// glyph followed by a plain space gutter. Used as the speaker-label
215// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
216// the bar reads consistently across the UI.
217func (t Theme) AccentBar(c int) string {
218	return t.FG256(c, "▌ ")
219}
220
221// Highlight paints s with the theme's selection colors (foreground +
222// background). The caller is responsible for padding s to the desired
223// width; styling alone does not extend the background past content.
224func (t Theme) Highlight(s string) string {
225	return t.SelectionStyle() + s + reset
226}
227
228// SelectionStyle returns the SGR prefix for the theme's selected row.
229func (t Theme) SelectionStyle() string {
230	return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
231}
232
233// BackgroundStyle returns the SGR prefix for the optional full-row
234// TUI background. Empty means zot should leave the terminal's
235// configured background untouched.
236func (t Theme) BackgroundStyle() string {
237	if t.Background == nil {
238		return ""
239	}
240	return sgrBGColor(*t.Background)
241}
242
243// SelectionStyleFG returns the SGR prefix for selected-row text with
244// a custom foreground. Useful for preserving semantic marks inside a
245// highlighted row without hardcoding escape sequences outside theme.go.
246func (t Theme) SelectionStyleFG(fg int) string {
247	return sgrFG(fg) + sgrBG(t.SelectionBG)
248}
249
250// PadHighlight styles s and extends the selection background to the
251// full terminal width so the highlight is a full row, not just a
252// rectangle around the text.
253func (t Theme) PadHighlight(s string, width int) string {
254	visible := visibleWidth(s)
255	if visible < width {
256		s += strings.Repeat(" ", width-visible)
257	}
258	return t.SelectionStyle() + s + reset
259}
260
261// UserBubble paints a single user message row with the bubble
262// background colour, padding to width so the tint extends to the
263// full terminal width. Foreground stays in UserBubbleFG so text
264// remains legible against the tint.
265func (t Theme) UserBubble(s string, width int) string {
266	visible := visibleWidth(s)
267	if visible < width {
268		s += strings.Repeat(" ", width-visible)
269	}
270	return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset
271}
272
273// UserBubbleRow renders one user-bubble row prefixed with a coloured
274// half-block accent bar ("▌ ") so every line of the bubble has the
275// zot-blue gutter at the very left. The bar lives outside the bubble
276// tint (chat bg) so the bubble itself sits inside it. Width is the
277// outer width including the bar; the bubble content is padded to
278// width-2 (the bar + its trailing space).
279func (t Theme) UserBubbleRow(content string, width int) string {
280	// Bar plus a single space gutter, in the assistant accent colour
281	// so it matches the tool-box / app accent and reads as zot's voice
282	// marker. Two cells wide.
283	bar := t.FG256(t.Assistant, "▌ ")
284	bubbleW := width - 2
285	if bubbleW < 1 {
286		bubbleW = 1
287	}
288	return bar + t.UserBubble(content, bubbleW)
289}
290
291// Bold wraps s in bold SGR.
292func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
293
294// Dim wraps s in dim SGR.
295func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
296
297// Italic wraps s in italic SGR.
298func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
299
300const reset = "\x1b[0m"
301
302func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }
303func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }
304func sgrBGColor(c TerminalColor) string {
305	switch c.Mode {
306	case terminalColorANSI:
307		return "\x1b[" + itoa(c.Index) + "m"
308	case terminalColorRGB:
309		return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"
310	default:
311		return sgrBG(c.Index)
312	}
313}
314
315// small itoa to avoid pulling strconv into this hot path twice.
316func itoa(n int) string {
317	if n == 0 {
318		return "0"
319	}
320	buf := [4]byte{}
321	i := len(buf)
322	for n > 0 {
323		i--
324		buf[i] = byte('0' + n%10)
325		n /= 10
326	}
327	return string(buf[i:])
328}

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