v0.2.5 16b95cb

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

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