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 thinking 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 "every draft is a stone in the work",
144 "bringing order to the unhewn",
145 "finding the load-bearing measure",
146 "where clarity grows, work grows lighter",
147 "every correction serves the work",
148}
149
150var Dark = Theme{
151 FG: 253,
152 Muted: 244,
153 Accent: 111, // soft blue
154 User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
155 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
156 UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
157 Assistant: 117, // bright cyan — the zot label color
158 Tool: 114, // green
159 ToolOut: 245,
160 Error: 203,
161 Warning: 214,
162 Spinner: 183, // soft purple
163 ThinkingMax: 207, // vivid magenta
164 SelectionBG: 24, // deep blue background
165 SelectionFG: 231, // near-white foreground
166 SpinnerFrames: defaultSpinnerFrames,
167 SpinnerMessages: defaultSpinnerMessages,
168 SpinnerIntervalMS: 80,
169 SyntaxBaseStyle: "monokai",
170 Syntax: nordSyntax,
171}
172
173var Light = Theme{
174 FG: 236,
175 Muted: 244,
176 Accent: 33,
177 User: 94,
178 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
179 UserBubbleFG: 240, // dark grey text, legible on the pale panel
180 Assistant: 31, // deep cyan
181 Tool: 28,
182 ToolOut: 240,
183 Error: 160,
184 Warning: 166,
185 Spinner: 91, // purple
186 ThinkingMax: 127, // deep magenta
187 SelectionBG: 153, // light blue
188 SelectionFG: 232, // near-black
189 SpinnerFrames: defaultSpinnerFrames,
190 SpinnerMessages: defaultSpinnerMessages,
191 SpinnerIntervalMS: 80,
192 SyntaxBaseStyle: "monokai",
193 Syntax: nordSyntax,
194}
195
196// FG256 wraps s in foreground color c using ANSI 256-color SGR.
197func (t Theme) FG256(c int, s string) string {
198 return sgrFG(c) + s + reset
199}
200
201// BG256 wraps s in background color c using ANSI 256-color SGR.
202// Useful when the visible cell needs a coloured fill but the
203// underlying character should be a regular space (so mouse-copy
204// from the terminal yields whitespace instead of a glyph).
205func (t Theme) BG256(c int, s string) string {
206 return sgrBG(c) + s + reset
207}
208
209// BG wraps s in a terminal background colour. Unlike BG256, this can
210// target ANSI theme slots or truecolor RGB, which lets selected UI
211// surfaces follow the user's terminal theme.
212func (t Theme) BG(c TerminalColor, s string) string {
213 return sgrBGColor(c) + s + reset
214}
215
216// AccentBar returns a 2-cell-wide leader: a coloured half-block
217// glyph followed by a plain space gutter. Used as the speaker-label
218// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
219// the bar reads consistently across the UI.
220func (t Theme) AccentBar(c int) string {
221 return t.FG256(c, "▌ ")
222}
223
224// Highlight paints s with the theme's selection colors (foreground +
225// background). The caller is responsible for padding s to the desired
226// width; styling alone does not extend the background past content.
227func (t Theme) Highlight(s string) string {
228 return t.SelectionStyle() + s + reset
229}
230
231// SelectionStyle returns the SGR prefix for the theme's selected row.
232func (t Theme) SelectionStyle() string {
233 return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
234}
235
236// BackgroundStyle returns the SGR prefix for the optional full-row
237// TUI background. Empty means zot should leave the terminal's
238// configured background untouched.
239func (t Theme) BackgroundStyle() string {
240 if t.Background == nil {
241 return ""
242 }
243 return sgrBGColor(*t.Background)
244}
245
246// SelectionStyleFG returns the SGR prefix for selected-row text with
247// a custom foreground. Useful for preserving semantic marks inside a
248// highlighted row without hardcoding escape sequences outside theme.go.
249func (t Theme) SelectionStyleFG(fg int) string {
250 return sgrFG(fg) + sgrBG(t.SelectionBG)
251}
252
253// PadHighlight styles s and extends the selection background to the
254// full terminal width so the highlight is a full row, not just a
255// rectangle around the text.
256func (t Theme) PadHighlight(s string, width int) string {
257 visible := visibleWidth(s)
258 if visible < width {
259 s += strings.Repeat(" ", width-visible)
260 }
261 return t.SelectionStyle() + s + reset
262}
263
264// UserBubble paints a single user message row with the bubble
265// background colour, padding to width so the tint extends to the
266// full terminal width. Foreground stays in UserBubbleFG so text
267// remains legible against the tint.
268func (t Theme) UserBubble(s string, width int) string {
269 visible := visibleWidth(s)
270 if visible < width {
271 s += strings.Repeat(" ", width-visible)
272 }
273 return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset
274}
275
276// UserBubbleRow renders one user-bubble row prefixed with a coloured
277// half-block accent bar ("▌ ") so every line of the bubble has the
278// zot-blue gutter at the very left. The bar lives outside the bubble
279// tint (chat bg) so the bubble itself sits inside it. Width is the
280// outer width including the bar; the bubble content is padded to
281// width-2 (the bar + its trailing space).
282func (t Theme) UserBubbleRow(content string, width int) string {
283 // Bar plus a single space gutter, in the assistant accent colour
284 // so it matches the tool-box / app accent and reads as zot's voice
285 // marker. Two cells wide.
286 bar := t.FG256(t.Assistant, "▌ ")
287 bubbleW := width - 2
288 if bubbleW < 1 {
289 bubbleW = 1
290 }
291 return bar + t.UserBubble(content, bubbleW)
292}
293
294// Bold wraps s in bold SGR.
295func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
296
297// Dim wraps s in dim SGR.
298func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
299
300// Italic wraps s in italic SGR.
301func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
302
303const reset = "\x1b[0m"
304
305func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }
306func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }
307func sgrBGColor(c TerminalColor) string {
308 switch c.Mode {
309 case terminalColorANSI:
310 return "\x1b[" + itoa(c.Index) + "m"
311 case terminalColorRGB:
312 return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"
313 default:
314 return sgrBG(c.Index)
315 }
316}
317
318// small itoa to avoid pulling strconv into this hot path twice.
319func itoa(n int) string {
320 if n == 0 {
321 return "0"
322 }
323 buf := [4]byte{}
324 i := len(buf)
325 for n > 0 {
326 i--
327 buf[i] = byte('0' + n%10)
328 n /= 10
329 }
330 return string(buf[i:])
331}