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 (
7 "strings"
8 "sync/atomic"
9)
10
11// TerminalColor describes a theme colour in one of the colour spaces
12// terminals commonly support. Every semantic theme colour uses this type;
13// xterm-256, ANSI, and RGB values are rendered according to terminal capability.
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// TerminalProfile is the best-effort color snapshot reported by the
37// controlling terminal. The ANSI palette is optional; unknown slots are
38// resolved through the classic xterm-256 palette.
39type TerminalProfile struct {
40 Foreground TerminalColor
41 Background TerminalColor
42
43 HasForeground bool
44 HasBackground bool
45
46 Palette [16]TerminalColor
47 PaletteKnown uint16
48 TrueColor bool
49 Light bool
50 SchemeKnown bool
51}
52
53// PaletteColor returns a reported ANSI palette entry when available.
54func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
55 if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 {
56 return TerminalColor{}, false
57 }
58 return p.Palette[index], true
59}
60
61// Semantic palette used by zut. Each role is a TerminalColor so themes can
62// use indexed, ANSI, or RGB values without changing render code.
63type Theme struct {
64 FG TerminalColor
65 Muted TerminalColor
66 Accent TerminalColor
67 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default
68 User TerminalColor // label color for the user role
69 UserBubbleBG TerminalColor // background tint behind user message rows
70 UserBubbleFG TerminalColor // foreground colour for user message rows
71 Assistant TerminalColor // label color for the zut role
72 Tool TerminalColor
73 ToolOut TerminalColor
74 Error TerminalColor
75 Warning TerminalColor
76 Spinner TerminalColor // spinner + funny working line
77 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
78 SelectionBG TerminalColor // background for highlighted rows
79 SelectionFG TerminalColor // foreground for highlighted rows
80
81 // Inherited selects terminal-owned colors. Terminal.TrueColor controls
82 // whether fixed indexed values are emitted as xterm-256 or truecolor and
83 // whether RGB values are quantized for terminals without direct-color
84 // support.
85 Inherited bool
86 Terminal TerminalProfile
87
88 SpinnerFrames []string
89 SpinnerMessages []string
90 SpinnerIntervalMS int
91
92 SyntaxBaseStyle string
93 Syntax SyntaxTheme
94}
95
96// SyntaxTheme contains the chroma token colors used by code fences,
97// file previews, and diffs. Values are chroma style entries, so they
98// may include attributes after the color (for example "#81a1c1 bold").
99type SyntaxTheme struct {
100 Keyword string
101 KeywordConstant string
102 KeywordDeclaration string
103 KeywordNamespace string
104 KeywordReserved string
105 KeywordType string
106 NameBuiltin string
107 NameFunction string
108 NameClass string
109 NameDecorator string
110 LiteralString string
111 LiteralStringEscape string
112 LiteralNumber string
113 Comment string
114 CommentPreproc string
115 Operator string
116 Punctuation string
117 Text string
118}
119
120var nordSyntax = SyntaxTheme{
121 Keyword: "#81a1c1 bold",
122 KeywordConstant: "#81a1c1",
123 KeywordDeclaration: "#81a1c1",
124 KeywordNamespace: "#81a1c1",
125 KeywordReserved: "#81a1c1 bold",
126 KeywordType: "#88c0d0",
127 NameBuiltin: "#88c0d0",
128 NameFunction: "#8fbcbb",
129 NameClass: "#a3be8c bold",
130 NameDecorator: "#b48ead",
131 LiteralString: "#a3be8c",
132 LiteralStringEscape: "#bf616a",
133 LiteralNumber: "#d08770",
134 Comment: "#616e88 italic",
135 CommentPreproc: "#b48ead",
136 Operator: "#eceff4",
137 Punctuation: "#d8dee9",
138 Text: "#e5e9f0",
139}
140
141var defaultSpinnerFrames = []string{
142 "⠋",
143 "⠙",
144 "⠚",
145 "⠞",
146 "⠖",
147 "⠦",
148 "⠴",
149 "⠲",
150 "⠳",
151 "⠓",
152}
153
154var defaultSpinnerMessages = []string{
155 "thinking",
156 "reticulating splines",
157 "bribing the tokenizer",
158 "asking the rubber duck",
159 "summoning daemons",
160 "consulting the oracle",
161 "herding tokens",
162 "compiling excuses",
163 "poking the model",
164 "negotiating with rate limits",
165 "picking a fight with syntax",
166 "reading between the bits",
167 "tasting the semicolons",
168 "pretending to understand the code",
169 "petting the cache",
170 "drafting clever replies",
171 "warming up the GPU choir",
172 "arguing with a stack trace",
173 "googling the answer (not really)",
174 "rewriting history",
175 "every draft is a stone in the work",
176 "bringing order to the unhewn",
177 "finding the load-bearing measure",
178 "where clarity grows, work grows lighter",
179 "every correction serves the work",
180}
181
182var Dark = Theme{
183 FG: Color256(253),
184 Muted: Color256(244),
185 Accent: Color256(111), // soft blue
186 User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat)
187 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
188 UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B
189 Assistant: Color256(117), // bright cyan — the zut label color
190 Tool: Color256(114), // green
191 ToolOut: Color256(245),
192 Error: Color256(203),
193 Warning: Color256(214),
194 Spinner: Color256(183), // soft purple
195 ThinkingMax: Color256(207), // vivid magenta
196 SelectionBG: Color256(24), // deep blue background
197 SelectionFG: Color256(231), // near-white foreground
198 SpinnerFrames: defaultSpinnerFrames,
199 SpinnerMessages: defaultSpinnerMessages,
200 SpinnerIntervalMS: 80,
201 SyntaxBaseStyle: "monokai",
202 Syntax: nordSyntax,
203}
204
205var Light = Theme{
206 FG: Color256(236),
207 Muted: Color256(244),
208 Accent: Color256(33),
209 User: Color256(94),
210 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
211 UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel
212 Assistant: Color256(31), // deep cyan
213 Tool: Color256(28),
214 ToolOut: Color256(240),
215 Error: Color256(160),
216 Warning: Color256(166),
217 Spinner: Color256(91), // purple
218 ThinkingMax: Color256(127), // deep magenta
219 SelectionBG: Color256(153), // light blue
220 SelectionFG: Color256(232), // near-black
221 SpinnerFrames: defaultSpinnerFrames,
222 SpinnerMessages: defaultSpinnerMessages,
223 SpinnerIntervalMS: 80,
224 SyntaxBaseStyle: "monokai",
225 Syntax: nordSyntax,
226}
227
228// FGColor wraps s in a foreground color. The name is intentionally explicit
229// because the color may be xterm-256, ANSI, or RGB.
230func (t Theme) FGColor(c TerminalColor, s string) string {
231 return t.fgPrefix(c) + s + reset
232}
233
234// FG256 retains the historical helper name while accepting the full
235// TerminalColor model.
236//
237// Deprecated: use FGColor.
238func (t Theme) FG256(c TerminalColor, s string) string {
239 return t.FGColor(c, s)
240}
241
242// BG256 retains the historical helper name while accepting the full
243// TerminalColor model.
244//
245// Deprecated: use BG.
246func (t Theme) BG256(c TerminalColor, s string) string {
247 return t.bgPrefix(c) + s + reset
248}
249
250// BG wraps s in a terminal background color. RGB values are quantized when
251// the active terminal does not advertise truecolor support.
252func (t Theme) BG(c TerminalColor, s string) string {
253 return t.bgPrefix(c) + s + reset
254}
255
256func (t Theme) fgPrefix(color TerminalColor) string {
257 return sgrFGColor(t.resolveColor(color))
258}
259
260func (t Theme) bgPrefix(color TerminalColor) string {
261 return sgrBGColor(t.resolveColor(color))
262}
263
264// DimColor fades a theme color toward the terminal background. It is used
265// by inherited theme construction and is also available to callers that need
266// terminal-aware dimming for overlays.
267func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
268 percent = clampPercent(percent)
269 foreground := t.resolveColor(color)
270 background := t.Terminal.Background
271 if !t.Terminal.HasBackground {
272 background = Color256(0)
273 }
274 fg, _ := rgbForTerminalColor(foreground)
275 bg, _ := rgbForTerminalColor(background)
276 return t.resolveColor(ColorRGB(
277 blendChannel(fg[0], bg[0], percent),
278 blendChannel(fg[1], bg[1], percent),
279 blendChannel(fg[2], bg[2], percent),
280 ))
281}
282
283func (t Theme) resolveColor(color TerminalColor) TerminalColor {
284 switch color.Mode {
285 case terminalColor256:
286 index := clampXtermIndex(color.Index)
287 if t.Terminal.TrueColor {
288 if t.Inherited {
289 if palette, ok := t.Terminal.PaletteColor(index); ok {
290 return palette
291 }
292 }
293 r, g, b := xterm256RGB(index)
294 return ColorRGB(r, g, b)
295 }
296 return Color256(index)
297 case terminalColorANSI:
298 if t.Inherited && t.Terminal.TrueColor {
299 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
300 if palette, ok := t.Terminal.PaletteColor(index); ok {
301 return palette
302 }
303 r, g, b := xterm256RGB(index)
304 return ColorRGB(r, g, b)
305 }
306 }
307 case terminalColorRGB:
308 if !t.Terminal.TrueColor {
309 return Color256(nearestXtermColor(color.R, color.G, color.B))
310 }
311 }
312 return color
313}
314
315func sgrFGColor(color TerminalColor) string {
316 switch color.Mode {
317 case terminalColorANSI:
318 return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
319 case terminalColorRGB:
320 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
321 default:
322 return sgrFG(clampXtermIndex(color.Index))
323 }
324}
325
326func (t Theme) sgrBGColor(color TerminalColor) string {
327 return sgrBGColor(t.resolveColor(color))
328}
329
330// AccentBar returns a 2-cell-wide leader: a coloured half-block
331// glyph followed by a plain space gutter. Used as the speaker-label
332// prefix in the chat ("▌ you", "▌ zut") and as the editor prompt so
333// the bar reads consistently across the UI.
334func (t Theme) AccentBar(c TerminalColor) string {
335 return t.FGColor(c, "▌ ")
336}
337
338// Highlight paints s with the theme's selection colors (foreground +
339// background). The caller is responsible for padding s to the desired
340// width; styling alone does not extend the background past content.
341func (t Theme) Highlight(s string) string {
342 return t.SelectionStyle() + s + reset
343}
344
345// SelectionStyle returns the SGR prefix for the theme's selected row.
346func (t Theme) SelectionStyle() string {
347 return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
348}
349
350// BackgroundStyle returns the SGR prefix for the optional full-row
351// TUI background. Empty means zut should leave the terminal's
352// configured background untouched.
353func (t Theme) BackgroundStyle() string {
354 if t.Background == nil {
355 return ""
356 }
357 return t.bgPrefix(*t.Background)
358}
359
360// SelectionStyleFG returns the SGR prefix for selected-row text with
361// a custom foreground. Useful for preserving semantic marks inside a
362// highlighted row without hardcoding escape sequences outside theme.go.
363func (t Theme) SelectionStyleFG(fg TerminalColor) string {
364 return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
365}
366
367// PadHighlight styles s and extends the selection background to the
368// full terminal width so the highlight is a full row, not just a
369// rectangle around the text.
370func (t Theme) PadHighlight(s string, width int) string {
371 visible := visibleWidth(s)
372 if visible < width {
373 s += strings.Repeat(" ", width-visible)
374 }
375 return t.SelectionStyle() + s + reset
376}
377
378// UserBubble paints a single user message row with the bubble
379// background colour, padding to width so the tint extends to the
380// full terminal width. Foreground stays in UserBubbleFG so text
381// remains legible against the tint.
382func (t Theme) UserBubble(s string, width int) string {
383 visible := visibleWidth(s)
384 if visible < width {
385 s += strings.Repeat(" ", width-visible)
386 }
387 return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
388}
389
390// UserBubbleRow renders one user-bubble row prefixed with a coloured
391// half-block accent bar ("▌ ") so every line of the bubble has the
392// zut-blue gutter at the very left. The bar lives outside the bubble
393// tint (chat bg) so the bubble itself sits inside it. Width is the
394// outer width including the bar; the bubble content is padded to
395// width-2 (the bar + its trailing space).
396func (t Theme) UserBubbleRow(content string, width int) string {
397 // Bar plus a single space gutter, in the assistant accent colour
398 // so it matches the tool-box / app accent and reads as zut's voice
399 // marker. Two cells wide.
400 bar := t.FGColor(t.Assistant, "▌ ")
401 bubbleW := width - 2
402 if bubbleW < 1 {
403 bubbleW = 1
404 }
405 return bar + t.UserBubble(content, bubbleW)
406}
407
408// Bold wraps s in bold SGR.
409func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
410
411// Dim wraps s in dim SGR.
412func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
413
414// Italic wraps s in italic SGR.
415func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
416
417const reset = "\x1b[0m"
418
419func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
420func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
421func sgrBGColor(c TerminalColor) string {
422 switch c.Mode {
423 case terminalColorANSI:
424 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
425 case terminalColorRGB:
426 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
427 default:
428 return sgrBG(c.Index)
429 }
430}
431
432func clampXtermIndex(index int) int {
433 if index < 0 {
434 return 0
435 }
436 if index > 255 {
437 return 255
438 }
439 return index
440}
441
442func clampByte(value int) int {
443 if value < 0 {
444 return 0
445 }
446 if value > 255 {
447 return 255
448 }
449 return value
450}
451
452func clampPercent(percent int) int {
453 if percent < 0 {
454 return 0
455 }
456 if percent > 100 {
457 return 100
458 }
459 return percent
460}
461
462func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
463 switch color.Mode {
464 case terminalColorRGB:
465 return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
466 case terminalColor256:
467 r, g, b := xterm256RGB(clampXtermIndex(color.Index))
468 return [3]int{r, g, b}, true
469 case terminalColorANSI:
470 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
471 r, g, b := xterm256RGB(index)
472 return [3]int{r, g, b}, true
473 }
474 }
475 return [3]int{}, false
476}
477
478func ansiSGRToXtermIndex(sgr int) (int, bool) {
479 switch {
480 case sgr >= 30 && sgr <= 37:
481 return sgr - 30, true
482 case sgr >= 90 && sgr <= 97:
483 return sgr - 90 + 8, true
484 case sgr >= 40 && sgr <= 47:
485 return sgr - 40, true
486 case sgr >= 100 && sgr <= 107:
487 return sgr - 100 + 8, true
488 default:
489 return 0, false
490 }
491}
492
493func ansiForegroundSGR(sgr int) int {
494 switch {
495 case sgr >= 40 && sgr <= 47:
496 return sgr - 10
497 case sgr >= 100 && sgr <= 107:
498 return sgr - 10
499 default:
500 return sgr
501 }
502}
503
504func ansiBackgroundSGR(sgr int) int {
505 switch {
506 case sgr >= 30 && sgr <= 37:
507 return sgr + 10
508 case sgr >= 90 && sgr <= 97:
509 return sgr + 10
510 default:
511 return sgr
512 }
513}
514
515func blendChannel(from, to, percent int) int {
516 return clampByte((from*(100-percent) + to*percent + 50) / 100)
517}
518
519const xtermColorCacheSize = 256
520
521type xtermColorCacheEntry struct {
522 value atomic.Uint64
523}
524
525var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
526
527func nearestXtermColor(r, g, b int) int {
528 r = clampByte(r)
529 g = clampByte(g)
530 b = clampByte(b)
531 key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
532 entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
533 cached := entry.value.Load()
534 keyPrefix := (key + 1) << 8
535 if cached&^uint64(0xff) == keyPrefix {
536 return int(cached & 0xff)
537 }
538
539 best := 0
540 bestDistance := int(^uint(0) >> 1)
541 for index := 0; index < 256; index++ {
542 cr, cg, cb := xterm256RGB(index)
543 dr := r - cr
544 dg := g - cg
545 db := b - cb
546 distance := dr*dr + dg*dg + db*db
547 if distance < bestDistance {
548 best = index
549 bestDistance = distance
550 }
551 }
552 entry.value.Store(keyPrefix | uint64(best))
553 return best
554}
555
556// small itoa to avoid pulling strconv into this hot path twice.
557func itoa(n int) string {
558 if n == 0 {
559 return "0"
560 }
561 buf := [4]byte{}
562 i := len(buf)
563 for n > 0 {
564 i--
565 buf[i] = byte('0' + n%10)
566 n /= 10
567 }
568 return string(buf[i:])
569}