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}