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 both explicit colors and terminal-owned colors.
12// TerminalDefault and TerminalPaletteSlot intentionally differ from literal
13// xterm-256 values: an adaptive theme follows the terminal palette while a
14// custom numeric value retains its xterm meaning.
15type TerminalColor struct {
16 Mode terminalColorMode
17 Index int
18 R int
19 G int
20 B int
21}
22
23type terminalColorMode int
24
25const (
26 terminalColorDefault terminalColorMode = iota
27 terminalColorPalette
28 terminalColor256
29 terminalColorANSI
30 terminalColorRGB
31)
32
33func TerminalDefault() TerminalColor { return TerminalColor{Mode: terminalColorDefault} }
34func TerminalPaletteSlot(index int) TerminalColor {
35 return TerminalColor{Mode: terminalColorPalette, Index: index}
36}
37func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} }
38func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} }
39func ColorRGB(r, g, b int) TerminalColor {
40 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
41}
42
43// ColorDepth is the terminal output capability used when resolving a color.
44type ColorDepth uint8
45
46const (
47 // Indexed256 is the zero value to preserve the legacy behavior for
48 // profiles assembled by callers that predate ColorDepth.
49 ColorDepthIndexed256 ColorDepth = iota
50 ColorDepthANSI16
51 ColorDepthTrueColor
52)
53
54// TerminalProfile is the best-effort color snapshot reported by the
55// controlling terminal. Palette entries are optional; an unknown slot is
56// emitted as its direct ANSI SGR rather than substituted with a fixed zut RGB.
57type TerminalProfile struct {
58 Foreground TerminalColor
59 Background TerminalColor
60
61 HasForeground bool
62 HasBackground bool
63
64 Palette [16]TerminalColor
65 PaletteKnown uint16
66 Depth ColorDepth
67 // TrueColor is retained for callers compiled against the older profile.
68 // Depth is authoritative when it is ColorDepthTrueColor.
69 TrueColor bool
70 Light bool
71 SchemeKnown bool
72}
73
74func (p TerminalProfile) ColorDepth() ColorDepth {
75 if p.Depth == ColorDepthTrueColor || p.TrueColor {
76 return ColorDepthTrueColor
77 }
78 if p.Depth == ColorDepthANSI16 {
79 return ColorDepthANSI16
80 }
81 return ColorDepthIndexed256
82}
83
84// PaletteColor returns a reported ANSI palette entry when available.
85func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
86 if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 {
87 return TerminalColor{}, false
88 }
89 return p.Palette[index], true
90}
91
92// Semantic palette used by zut. Each role is a TerminalColor so themes can
93// use indexed, ANSI, or RGB values without changing render code.
94type Theme struct {
95 FG TerminalColor
96 Muted TerminalColor
97 Accent TerminalColor
98 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default
99 User TerminalColor // label color for the user role
100 UserBubbleBG TerminalColor // background tint behind user message rows
101 UserBubbleFG TerminalColor // foreground colour for user message rows
102 Assistant TerminalColor // label color for the zut role
103 Tool TerminalColor
104 ToolOut TerminalColor
105 Error TerminalColor
106 Warning TerminalColor
107 Spinner TerminalColor // spinner frame and activity label
108 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
109 SelectionBG TerminalColor // background for highlighted rows
110 SelectionFG TerminalColor // foreground for highlighted rows
111
112 // UseTerminalPalette resolves TerminalPaletteSlot roles against the
113 // reported palette on truecolor terminals. It has no effect on explicit
114 // Color256 values from a custom theme.
115 UseTerminalPalette bool
116 Terminal TerminalProfile
117
118 SpinnerFrames []string
119 SpinnerIntervalMS int
120
121 SyntaxBaseStyle string
122 Syntax SyntaxTheme
123}
124
125// SyntaxTheme contains the chroma token colors used by code fences,
126// file previews, and diffs. Values are chroma style entries, so they
127// may include attributes after the color (for example "#81a1c1 bold").
128type SyntaxTheme struct {
129 Keyword string
130 KeywordConstant string
131 KeywordDeclaration string
132 KeywordNamespace string
133 KeywordReserved string
134 KeywordType string
135 NameBuiltin string
136 NameFunction string
137 NameClass string
138 NameDecorator string
139 LiteralString string
140 LiteralStringEscape string
141 LiteralNumber string
142 Comment string
143 CommentPreproc string
144 Operator string
145 Punctuation string
146 Text string
147}
148
149var nordSyntax = SyntaxTheme{
150 Keyword: "#81a1c1 bold",
151 KeywordConstant: "#81a1c1",
152 KeywordDeclaration: "#81a1c1",
153 KeywordNamespace: "#81a1c1",
154 KeywordReserved: "#81a1c1 bold",
155 KeywordType: "#88c0d0",
156 NameBuiltin: "#88c0d0",
157 NameFunction: "#8fbcbb",
158 NameClass: "#a3be8c bold",
159 NameDecorator: "#b48ead",
160 LiteralString: "#a3be8c",
161 LiteralStringEscape: "#bf616a",
162 LiteralNumber: "#d08770",
163 Comment: "#616e88 italic",
164 CommentPreproc: "#b48ead",
165 Operator: "#eceff4",
166 Punctuation: "#d8dee9",
167 Text: "#e5e9f0",
168}
169
170var defaultSpinnerFrames = []string{
171 "⠋",
172 "⠙",
173 "⠚",
174 "⠞",
175 "⠖",
176 "⠦",
177 "⠴",
178 "⠲",
179 "⠳",
180 "⠓",
181}
182
183var Dark = Theme{
184 FG: Color256(253),
185 Muted: Color256(244),
186 Accent: Color256(111), // soft blue
187 User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat)
188 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
189 UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B
190 Assistant: Color256(117), // bright cyan — the zut label color
191 Tool: Color256(114), // green
192 ToolOut: Color256(245),
193 Error: Color256(203),
194 Warning: Color256(214),
195 Spinner: Color256(183), // soft purple
196 ThinkingMax: Color256(207), // vivid magenta
197 SelectionBG: Color256(24), // deep blue background
198 SelectionFG: Color256(231), // near-white foreground
199 SpinnerFrames: defaultSpinnerFrames,
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 SpinnerIntervalMS: 80,
223 SyntaxBaseStyle: "monokai",
224 Syntax: nordSyntax,
225}
226
227// FGColor wraps s in a foreground color. The name is intentionally explicit
228// because the color may be xterm-256, ANSI, or RGB.
229func (t Theme) FGColor(c TerminalColor, s string) string {
230 return t.fgPrefix(c) + s + reset
231}
232
233// FG256 retains the historical helper name while accepting the full
234// TerminalColor model.
235//
236// Deprecated: use FGColor.
237func (t Theme) FG256(c TerminalColor, s string) string {
238 return t.FGColor(c, s)
239}
240
241// BG256 retains the historical helper name while accepting the full
242// TerminalColor model.
243//
244// Deprecated: use BG.
245func (t Theme) BG256(c TerminalColor, s string) string {
246 return t.bgPrefix(c) + s + reset
247}
248
249// BG wraps s in a terminal background color. RGB values are quantized when
250// the active terminal does not advertise truecolor support.
251func (t Theme) BG(c TerminalColor, s string) string {
252 return t.bgPrefix(c) + s + reset
253}
254
255func (t Theme) fgPrefix(color TerminalColor) string {
256 return sgrFGColor(t.resolveColor(color))
257}
258
259func (t Theme) bgPrefix(color TerminalColor) string {
260 return sgrBGColor(t.resolveColor(color))
261}
262
263// DimColor fades a theme color toward the terminal background. It is used
264// while resolving adaptive terminal surfaces and by overlay callers.
265func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
266 percent = clampPercent(percent)
267 foreground := t.resolveColor(color)
268 background := t.Terminal.Background
269 if !t.Terminal.HasBackground {
270 background = Color256(0)
271 }
272 fg, _ := rgbForTerminalColor(foreground)
273 bg, _ := rgbForTerminalColor(background)
274 return t.resolveColor(ColorRGB(
275 blendChannel(fg[0], bg[0], percent),
276 blendChannel(fg[1], bg[1], percent),
277 blendChannel(fg[2], bg[2], percent),
278 ))
279}
280
281func (t Theme) resolveColor(color TerminalColor) TerminalColor {
282 depth := t.Terminal.ColorDepth()
283 switch color.Mode {
284 case terminalColorDefault:
285 return color
286 case terminalColorPalette:
287 index := clampANSIIndex(color.Index)
288 if depth == ColorDepthTrueColor {
289 if palette, ok := t.Terminal.PaletteColor(index); ok {
290 return palette
291 }
292 }
293 return ColorANSI(ansiIndexForegroundSGR(index))
294 case terminalColor256:
295 index := clampXtermIndex(color.Index)
296 switch depth {
297 case ColorDepthTrueColor:
298 r, g, b := xterm256RGB(index)
299 return ColorRGB(r, g, b)
300 case ColorDepthANSI16:
301 return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(index)))
302 default:
303 return Color256(index)
304 }
305 case terminalColorANSI:
306 if depth == ColorDepthTrueColor {
307 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
308 if palette, ok := t.Terminal.PaletteColor(index); ok {
309 return palette
310 }
311 }
312 }
313 case terminalColorRGB:
314 if depth == ColorDepthANSI16 {
315 return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(nearestXtermColor(color.R, color.G, color.B))))
316 }
317 if depth != ColorDepthTrueColor {
318 return Color256(nearestXtermColor(color.R, color.G, color.B))
319 }
320 }
321 return color
322}
323
324func sgrFGColor(color TerminalColor) string {
325 switch color.Mode {
326 case terminalColorDefault:
327 return "\x1b[39m"
328 case terminalColorANSI:
329 return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
330 case terminalColorRGB:
331 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
332 default:
333 return sgrFG(clampXtermIndex(color.Index))
334 }
335}
336
337func (t Theme) sgrBGColor(color TerminalColor) string {
338 return sgrBGColor(t.resolveColor(color))
339}
340
341// AccentBar returns a 2-cell-wide leader: a coloured half-block
342// glyph followed by a plain space gutter. Used as the speaker-label
343// prefix in the chat ("▌ you", "▌ zut") and as the editor prompt so
344// the bar reads consistently across the UI.
345func (t Theme) AccentBar(c TerminalColor) string {
346 return t.FGColor(c, "▌ ")
347}
348
349// Highlight paints s with the theme's selection colors (foreground +
350// background). The caller is responsible for padding s to the desired
351// width; styling alone does not extend the background past content.
352func (t Theme) Highlight(s string) string {
353 return t.SelectionStyle() + s + reset
354}
355
356// SelectionStyle returns the SGR prefix for the theme's selected row.
357func (t Theme) SelectionStyle() string {
358 return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG)
359}
360
361// BackgroundStyle returns the SGR prefix for the optional full-row
362// TUI background. Empty means zut should leave the terminal's
363// configured background untouched.
364func (t Theme) BackgroundStyle() string {
365 if t.Background == nil {
366 return ""
367 }
368 return t.bgPrefix(*t.Background)
369}
370
371// SelectionStyleFG returns the SGR prefix for selected-row text with
372// a custom foreground. Useful for preserving semantic marks inside a
373// highlighted row without hardcoding escape sequences outside theme.go.
374func (t Theme) SelectionStyleFG(fg TerminalColor) string {
375 return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG)
376}
377
378// PadHighlight styles s and extends the selection background to the
379// full terminal width so the highlight is a full row, not just a
380// rectangle around the text.
381func (t Theme) PadHighlight(s string, width int) string {
382 visible := visibleWidth(s)
383 if visible < width {
384 s += strings.Repeat(" ", width-visible)
385 }
386 return t.SelectionStyle() + s + reset
387}
388
389// UserBubble paints a single user message row with the bubble
390// background colour, padding to width so the tint extends to the
391// full terminal width. Foreground stays in UserBubbleFG so text
392// remains legible against the tint.
393func (t Theme) UserBubble(s string, width int) string {
394 visible := visibleWidth(s)
395 if visible < width {
396 s += strings.Repeat(" ", width-visible)
397 }
398 return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
399}
400
401// UserBubbleRow renders one user-bubble row prefixed with a coloured
402// half-block accent bar ("▌ ") so every line of the bubble has the
403// zut-blue gutter at the very left. The bar lives outside the bubble
404// tint (chat bg) so the bubble itself sits inside it. Width is the
405// outer width including the bar; the bubble content is padded to
406// width-2 (the bar + its trailing space).
407func (t Theme) UserBubbleRow(content string, width int) string {
408 // Bar plus a single space gutter, in the assistant accent colour
409 // so it matches the tool-box / app accent and reads as zut's voice
410 // marker. Two cells wide.
411 bar := t.FGColor(t.Assistant, "▌ ")
412 bubbleW := width - 2
413 if bubbleW < 1 {
414 bubbleW = 1
415 }
416 return bar + t.UserBubble(content, bubbleW)
417}
418
419const (
420 reset = "\x1b[0m"
421 dim = "\x1b[2m"
422 normalIntensity = "\x1b[22m"
423)
424
425var dimStyleReplacer = strings.NewReplacer(
426 reset, reset+dim,
427 normalIntensity, normalIntensity+dim,
428)
429
430// Bold wraps s in bold SGR.
431func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
432
433// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
434// independently styled segments remain dimmed too. Like Bold and Italic, it
435// restores only its own attribute so callers can safely compose styles.
436func Dim(s string) string {
437 return dim + dimStyleReplacer.Replace(s) + normalIntensity
438}
439
440// DimLines returns a dimmed copy of lines. It is suitable for content behind
441// a modal layer: the foreground layer can retain its normal styling while
442// every styled segment in the background stays dimmed.
443func DimLines(lines []string) []string {
444 out := make([]string, len(lines))
445 for idx, line := range lines {
446 out[idx] = Dim(line)
447 }
448 return out
449}
450
451// Italic wraps s in italic SGR.
452func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
453
454func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
455func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
456func sgrBGColor(c TerminalColor) string {
457 switch c.Mode {
458 case terminalColorDefault:
459 return "\x1b[49m"
460 case terminalColorANSI:
461 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
462 case terminalColorRGB:
463 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
464 default:
465 return sgrBG(c.Index)
466 }
467}
468
469func clampANSIIndex(index int) int {
470 if index < 0 {
471 return 0
472 }
473 if index > 15 {
474 return 15
475 }
476 return index
477}
478
479func ansiIndexForegroundSGR(index int) int {
480 index = clampANSIIndex(index)
481 if index < 8 {
482 return 30 + index
483 }
484 return 90 + index - 8
485}
486
487func nearestANSIColor(index int) int {
488 r, g, b := xterm256RGB(clampXtermIndex(index))
489 best, bestDistance := 0, int(^uint(0)>>1)
490 for slot := 0; slot < 16; slot++ {
491 pr, pg, pb := xterm256RGB(slot)
492 dr, dg, db := r-pr, g-pg, b-pb
493 distance := dr*dr + dg*dg + db*db
494 if distance < bestDistance {
495 best, bestDistance = slot, distance
496 }
497 }
498 return best
499}
500
501func clampXtermIndex(index int) int {
502 if index < 0 {
503 return 0
504 }
505 if index > 255 {
506 return 255
507 }
508 return index
509}
510
511func clampByte(value int) int {
512 if value < 0 {
513 return 0
514 }
515 if value > 255 {
516 return 255
517 }
518 return value
519}
520
521func clampPercent(percent int) int {
522 if percent < 0 {
523 return 0
524 }
525 if percent > 100 {
526 return 100
527 }
528 return percent
529}
530
531func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
532 switch color.Mode {
533 case terminalColorRGB:
534 return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
535 case terminalColor256:
536 r, g, b := xterm256RGB(clampXtermIndex(color.Index))
537 return [3]int{r, g, b}, true
538 case terminalColorPalette:
539 r, g, b := xterm256RGB(clampANSIIndex(color.Index))
540 return [3]int{r, g, b}, true
541 case terminalColorANSI:
542 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
543 r, g, b := xterm256RGB(index)
544 return [3]int{r, g, b}, true
545 }
546 }
547 return [3]int{}, false
548}
549
550func ansiSGRToXtermIndex(sgr int) (int, bool) {
551 switch {
552 case sgr >= 30 && sgr <= 37:
553 return sgr - 30, true
554 case sgr >= 90 && sgr <= 97:
555 return sgr - 90 + 8, true
556 case sgr >= 40 && sgr <= 47:
557 return sgr - 40, true
558 case sgr >= 100 && sgr <= 107:
559 return sgr - 100 + 8, true
560 default:
561 return 0, false
562 }
563}
564
565func ansiForegroundSGR(sgr int) int {
566 switch {
567 case sgr >= 40 && sgr <= 47:
568 return sgr - 10
569 case sgr >= 100 && sgr <= 107:
570 return sgr - 10
571 default:
572 return sgr
573 }
574}
575
576func ansiBackgroundSGR(sgr int) int {
577 switch {
578 case sgr >= 30 && sgr <= 37:
579 return sgr + 10
580 case sgr >= 90 && sgr <= 97:
581 return sgr + 10
582 default:
583 return sgr
584 }
585}
586
587func blendChannel(from, to, percent int) int {
588 return clampByte((from*(100-percent) + to*percent + 50) / 100)
589}
590
591const xtermColorCacheSize = 256
592
593type xtermColorCacheEntry struct {
594 value atomic.Uint64
595}
596
597var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
598
599func nearestXtermColor(r, g, b int) int {
600 r = clampByte(r)
601 g = clampByte(g)
602 b = clampByte(b)
603 key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
604 entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
605 cached := entry.value.Load()
606 keyPrefix := (key + 1) << 8
607 if cached&^uint64(0xff) == keyPrefix {
608 return int(cached & 0xff)
609 }
610
611 best := 0
612 bestDistance := int(^uint(0) >> 1)
613 for index := 0; index < 256; index++ {
614 cr, cg, cb := xterm256RGB(index)
615 dr := r - cr
616 dg := g - cg
617 db := b - cb
618 distance := dr*dr + dg*dg + db*db
619 if distance < bestDistance {
620 best = index
621 bestDistance = distance
622 }
623 }
624 entry.value.Store(keyPrefix | uint64(best))
625 return best
626}
627
628// small itoa to avoid pulling strconv into this hot path twice.
629func itoa(n int) string {
630 if n == 0 {
631 return "0"
632 }
633 buf := [4]byte{}
634 i := len(buf)
635 for n > 0 {
636 i--
637 buf[i] = byte('0' + n%10)
638 n /= 10
639 }
640 return string(buf[i:])
641}