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