v0.4.7 2ff82fa

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	ThinkingMax  int // status color for the opt-in max reasoning level
53	SelectionBG  int // background for highlighted rows
54	SelectionFG  int // foreground for highlighted rows
55
56	SpinnerFrames     []string
57	SpinnerMessages   []string
58	SpinnerIntervalMS int
59
60	SyntaxBaseStyle string
61	Syntax          SyntaxTheme
62}
63
64// SyntaxTheme contains the chroma token colors used by code fences,
65// file previews, and diffs. Values are chroma style entries, so they
66// may include attributes after the color (for example "#81a1c1 bold").
67type SyntaxTheme struct {
68	Keyword             string
69	KeywordConstant     string
70	KeywordDeclaration  string
71	KeywordNamespace    string
72	KeywordReserved     string
73	KeywordType         string
74	NameBuiltin         string
75	NameFunction        string
76	NameClass           string
77	NameDecorator       string
78	LiteralString       string
79	LiteralStringEscape string
80	LiteralNumber       string
81	Comment             string
82	CommentPreproc      string
83	Operator            string
84	Punctuation         string
85	Text                string
86}
87
88var nordSyntax = SyntaxTheme{
89	Keyword:             "#81a1c1 bold",
90	KeywordConstant:     "#81a1c1",
91	KeywordDeclaration:  "#81a1c1",
92	KeywordNamespace:    "#81a1c1",
93	KeywordReserved:     "#81a1c1 bold",
94	KeywordType:         "#88c0d0",
95	NameBuiltin:         "#88c0d0",
96	NameFunction:        "#8fbcbb",
97	NameClass:           "#a3be8c bold",
98	NameDecorator:       "#b48ead",
99	LiteralString:       "#a3be8c",
100	LiteralStringEscape: "#bf616a",
101	LiteralNumber:       "#d08770",
102	Comment:             "#616e88 italic",
103	CommentPreproc:      "#b48ead",
104	Operator:            "#eceff4",
105	Punctuation:         "#d8dee9",
106	Text:                "#e5e9f0",
107}
108
109var defaultSpinnerFrames = []string{
110	"⠋",
111	"⠙",
112	"⠚",
113	"⠞",
114	"⠖",
115	"⠦",
116	"⠴",
117	"⠲",
118	"⠳",
119	"⠓",
120}
121
122var defaultSpinnerMessages = []string{
123	"thinking",
124	"reticulating splines",
125	"bribing the tokenizer",
126	"asking the rubber duck",
127	"summoning daemons",
128	"consulting the oracle",
129	"herding tokens",
130	"compiling excuses",
131	"poking the model",
132	"negotiating with rate limits",
133	"picking a fight with syntax",
134	"reading between the bits",
135	"tasting the semicolons",
136	"pretending to understand the code",
137	"petting the cache",
138	"drafting clever replies",
139	"warming up the GPU choir",
140	"arguing with a stack trace",
141	"googling the answer (not really)",
142	"rewriting history",
143	"quantizing confidence",
144	"marinating the prompt",
145	"improvising deterministically",
146	"duct-taping inference",
147	"shaking out bugs",
148	"optimizing for vibes",
149	"seasoning the output",
150	"tuning the nonsense",
151	"borrowing more RAM",
152	"hydrating context",
153	"feeding the decoder",
154	"thrashing the cache",
155	"starving the GPU",
156	"decoding consequences",
157	"tool-calling recklessly",
158	"masking the panic",
159	"escaping recursion",
160	"hallucinating responsibly",
161	"routing around doom",
162	"tokenizing destiny",
163	"indexing the void",
164	"forking the timeline",
165	"routing raccoons",
166	"patching spacetime",
167	"allocating spectral resources",
168	"sampling forbidden geometry",
169	"distilling bad decisions",
170	"vectorizing intrusive thoughts",
171	"sampling cursed logits",
172	"ranking imaginary futures",
173	"overflowing the window",
174	"leasing neural horsepower",
175	"vacuuming attention residue",
176	"softmaxing reality",
177	"truncating childhood memories",
178	"spending probability mass",
179	"paying the token toll",
180	"renting datacenter wisdom",
181	"downgrading gracefully",
182}
183
184var Dark = Theme{
185	FG:                253,
186	Muted:             244,
187	Accent:            111,                  // soft blue
188	User:              180,                  // warm tan (unused now that the speaker label is gone, kept for skin compat)
189	UserBubbleBG:      ColorRGB(66, 69, 75), // #42454B
190	UserBubbleFG:      248,                  // slightly lighter grey for readability on #42454B
191	Assistant:         117,                  // bright cyan — the zot label color
192	Tool:              114,                  // green
193	ToolOut:           245,
194	Error:             203,
195	Warning:           214,
196	Spinner:           183, // soft purple
197	ThinkingMax:       207, // vivid magenta
198	SelectionBG:       24,  // deep blue background
199	SelectionFG:       231, // near-white foreground
200	SpinnerFrames:     defaultSpinnerFrames,
201	SpinnerMessages:   defaultSpinnerMessages,
202	SpinnerIntervalMS: 80,
203	SyntaxBaseStyle:   "monokai",
204	Syntax:            nordSyntax,
205}
206
207var Light = Theme{
208	FG:                236,
209	Muted:             244,
210	Accent:            33,
211	User:              94,
212	UserBubbleBG:      Color256(254), // very pale grey panel behind user rows on light theme
213	UserBubbleFG:      240,           // dark grey text, legible on the pale panel
214	Assistant:         31,            // deep cyan
215	Tool:              28,
216	ToolOut:           240,
217	Error:             160,
218	Warning:           166,
219	Spinner:           91,  // purple
220	ThinkingMax:       127, // deep magenta
221	SelectionBG:       153, // light blue
222	SelectionFG:       232, // near-black
223	SpinnerFrames:     defaultSpinnerFrames,
224	SpinnerMessages:   defaultSpinnerMessages,
225	SpinnerIntervalMS: 80,
226	SyntaxBaseStyle:   "monokai",
227	Syntax:            nordSyntax,
228}
229
230// FG256 wraps s in foreground color c using ANSI 256-color SGR.
231func (t Theme) FG256(c int, s string) string {
232	return sgrFG(c) + s + reset
233}
234
235// BG256 wraps s in background color c using ANSI 256-color SGR.
236// Useful when the visible cell needs a coloured fill but the
237// underlying character should be a regular space (so mouse-copy
238// from the terminal yields whitespace instead of a glyph).
239func (t Theme) BG256(c int, s string) string {
240	return sgrBG(c) + s + reset
241}
242
243// BG wraps s in a terminal background colour. Unlike BG256, this can
244// target ANSI theme slots or truecolor RGB, which lets selected UI
245// surfaces follow the user's terminal theme.
246func (t Theme) BG(c TerminalColor, s string) string {
247	return sgrBGColor(c) + s + reset
248}
249
250// AccentBar returns a 2-cell-wide leader: a coloured half-block
251// glyph followed by a plain space gutter. Used as the speaker-label
252// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
253// the bar reads consistently across the UI.
254func (t Theme) AccentBar(c int) string {
255	return t.FG256(c, "▌ ")
256}
257
258// Highlight paints s with the theme's selection colors (foreground +
259// background). The caller is responsible for padding s to the desired
260// width; styling alone does not extend the background past content.
261func (t Theme) Highlight(s string) string {
262	return t.SelectionStyle() + s + reset
263}
264
265// SelectionStyle returns the SGR prefix for the theme's selected row.
266func (t Theme) SelectionStyle() string {
267	return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
268}
269
270// BackgroundStyle returns the SGR prefix for the optional full-row
271// TUI background. Empty means zot should leave the terminal's
272// configured background untouched.
273func (t Theme) BackgroundStyle() string {
274	if t.Background == nil {
275		return ""
276	}
277	return sgrBGColor(*t.Background)
278}
279
280// SelectionStyleFG returns the SGR prefix for selected-row text with
281// a custom foreground. Useful for preserving semantic marks inside a
282// highlighted row without hardcoding escape sequences outside theme.go.
283func (t Theme) SelectionStyleFG(fg int) string {
284	return sgrFG(fg) + sgrBG(t.SelectionBG)
285}
286
287// PadHighlight styles s and extends the selection background to the
288// full terminal width so the highlight is a full row, not just a
289// rectangle around the text.
290func (t Theme) PadHighlight(s string, width int) string {
291	visible := visibleWidth(s)
292	if visible < width {
293		s += strings.Repeat(" ", width-visible)
294	}
295	return t.SelectionStyle() + s + reset
296}
297
298// UserBubble paints a single user message row with the bubble
299// background colour, padding to width so the tint extends to the
300// full terminal width. Foreground stays in UserBubbleFG so text
301// remains legible against the tint.
302func (t Theme) UserBubble(s string, width int) string {
303	visible := visibleWidth(s)
304	if visible < width {
305		s += strings.Repeat(" ", width-visible)
306	}
307	return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset
308}
309
310// UserBubbleRow renders one user-bubble row prefixed with a coloured
311// half-block accent bar ("▌ ") so every line of the bubble has the
312// zot-blue gutter at the very left. The bar lives outside the bubble
313// tint (chat bg) so the bubble itself sits inside it. Width is the
314// outer width including the bar; the bubble content is padded to
315// width-2 (the bar + its trailing space).
316func (t Theme) UserBubbleRow(content string, width int) string {
317	// Bar plus a single space gutter, in the assistant accent colour
318	// so it matches the tool-box / app accent and reads as zot's voice
319	// marker. Two cells wide.
320	bar := t.FG256(t.Assistant, "▌ ")
321	bubbleW := width - 2
322	if bubbleW < 1 {
323		bubbleW = 1
324	}
325	return bar + t.UserBubble(content, bubbleW)
326}
327
328// Bold wraps s in bold SGR.
329func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
330
331// Dim wraps s in dim SGR.
332func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
333
334// Italic wraps s in italic SGR.
335func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
336
337const reset = "\x1b[0m"
338
339func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }
340func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }
341func sgrBGColor(c TerminalColor) string {
342	switch c.Mode {
343	case terminalColorANSI:
344		return "\x1b[" + itoa(c.Index) + "m"
345	case terminalColorRGB:
346		return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"
347	default:
348		return sgrBG(c.Index)
349	}
350}
351
352// small itoa to avoid pulling strconv into this hot path twice.
353func itoa(n int) string {
354	if n == 0 {
355		return "0"
356	}
357	buf := [4]byte{}
358	i := len(buf)
359	for n > 0 {
360		i--
361		buf[i] = byte('0' + n%10)
362		n /= 10
363	}
364	return string(buf[i:])
365}

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