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
408const (
409 reset = "\x1b[0m"
410 dim = "\x1b[2m"
411 normalIntensity = "\x1b[22m"
412)
413
414var dimStyleReplacer = strings.NewReplacer(
415 reset, reset+dim,
416 normalIntensity, normalIntensity+dim,
417)
418
419// Bold wraps s in bold SGR.
420func Bold(s string) string { return "\x1b[1m" + s + normalIntensity }
421
422// Dim wraps s in dim SGR. Re-apply dim after sequences that clear faint so
423// independently styled segments remain dimmed too. Like Bold and Italic, it
424// restores only its own attribute so callers can safely compose styles.
425func Dim(s string) string {
426 return dim + dimStyleReplacer.Replace(s) + normalIntensity
427}
428
429// DimLines returns a dimmed copy of lines. It is suitable for content behind
430// a modal layer: the foreground layer can retain its normal styling while
431// every styled segment in the background stays dimmed.
432func DimLines(lines []string) []string {
433 out := make([]string, len(lines))
434 for idx, line := range lines {
435 out[idx] = Dim(line)
436 }
437 return out
438}
439
440// Italic wraps s in italic SGR.
441func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
442
443func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
444func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
445func sgrBGColor(c TerminalColor) string {
446 switch c.Mode {
447 case terminalColorANSI:
448 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
449 case terminalColorRGB:
450 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
451 default:
452 return sgrBG(c.Index)
453 }
454}
455
456func clampXtermIndex(index int) int {
457 if index < 0 {
458 return 0
459 }
460 if index > 255 {
461 return 255
462 }
463 return index
464}
465
466func clampByte(value int) int {
467 if value < 0 {
468 return 0
469 }
470 if value > 255 {
471 return 255
472 }
473 return value
474}
475
476func clampPercent(percent int) int {
477 if percent < 0 {
478 return 0
479 }
480 if percent > 100 {
481 return 100
482 }
483 return percent
484}
485
486func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
487 switch color.Mode {
488 case terminalColorRGB:
489 return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
490 case terminalColor256:
491 r, g, b := xterm256RGB(clampXtermIndex(color.Index))
492 return [3]int{r, g, b}, true
493 case terminalColorANSI:
494 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
495 r, g, b := xterm256RGB(index)
496 return [3]int{r, g, b}, true
497 }
498 }
499 return [3]int{}, false
500}
501
502func ansiSGRToXtermIndex(sgr int) (int, bool) {
503 switch {
504 case sgr >= 30 && sgr <= 37:
505 return sgr - 30, true
506 case sgr >= 90 && sgr <= 97:
507 return sgr - 90 + 8, true
508 case sgr >= 40 && sgr <= 47:
509 return sgr - 40, true
510 case sgr >= 100 && sgr <= 107:
511 return sgr - 100 + 8, true
512 default:
513 return 0, false
514 }
515}
516
517func ansiForegroundSGR(sgr int) int {
518 switch {
519 case sgr >= 40 && sgr <= 47:
520 return sgr - 10
521 case sgr >= 100 && sgr <= 107:
522 return sgr - 10
523 default:
524 return sgr
525 }
526}
527
528func ansiBackgroundSGR(sgr int) int {
529 switch {
530 case sgr >= 30 && sgr <= 37:
531 return sgr + 10
532 case sgr >= 90 && sgr <= 97:
533 return sgr + 10
534 default:
535 return sgr
536 }
537}
538
539func blendChannel(from, to, percent int) int {
540 return clampByte((from*(100-percent) + to*percent + 50) / 100)
541}
542
543const xtermColorCacheSize = 256
544
545type xtermColorCacheEntry struct {
546 value atomic.Uint64
547}
548
549var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
550
551func nearestXtermColor(r, g, b int) int {
552 r = clampByte(r)
553 g = clampByte(g)
554 b = clampByte(b)
555 key := uint64(r)<<16 | uint64(g)<<8 | uint64(b)
556 entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)]
557 cached := entry.value.Load()
558 keyPrefix := (key + 1) << 8
559 if cached&^uint64(0xff) == keyPrefix {
560 return int(cached & 0xff)
561 }
562
563 best := 0
564 bestDistance := int(^uint(0) >> 1)
565 for index := 0; index < 256; index++ {
566 cr, cg, cb := xterm256RGB(index)
567 dr := r - cr
568 dg := g - cg
569 db := b - cb
570 distance := dr*dr + dg*dg + db*db
571 if distance < bestDistance {
572 best = index
573 bestDistance = distance
574 }
575 }
576 entry.value.Store(keyPrefix | uint64(best))
577 return best
578}
579
580// small itoa to avoid pulling strconv into this hot path twice.
581func itoa(n int) string {
582 if n == 0 {
583 return "0"
584 }
585 buf := [4]byte{}
586 i := len(buf)
587 for n > 0 {
588 i--
589 buf[i] = byte('0' + n%10)
590 n /= 10
591 }
592 return string(buf[i:])
593}