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 User int // label color for the user role
43 UserBubbleBG TerminalColor // background tint behind user message rows
44 UserBubbleFG int // foreground colour for user message rows
45 Assistant int // label color for the zot role
46 Tool int
47 ToolOut int
48 Error int
49 Warning int
50 Spinner int // spinner + funny working line
51 SelectionBG int // background for highlighted rows
52 SelectionFG int // foreground for highlighted rows
53
54 SpinnerFrames []string
55 SpinnerMessages []string
56 SpinnerIntervalMS int
57
58 SyntaxBaseStyle string
59 Syntax SyntaxTheme
60}
61
62// SyntaxTheme contains the chroma token colors used by code fences,
63// file previews, and diffs. Values are chroma style entries, so they
64// may include attributes after the color (for example "#81a1c1 bold").
65type SyntaxTheme struct {
66 Keyword string
67 KeywordConstant string
68 KeywordDeclaration string
69 KeywordNamespace string
70 KeywordReserved string
71 KeywordType string
72 NameBuiltin string
73 NameFunction string
74 NameClass string
75 NameDecorator string
76 LiteralString string
77 LiteralStringEscape string
78 LiteralNumber string
79 Comment string
80 CommentPreproc string
81 Operator string
82 Punctuation string
83 Text string
84}
85
86var nordSyntax = SyntaxTheme{
87 Keyword: "#81a1c1 bold",
88 KeywordConstant: "#81a1c1",
89 KeywordDeclaration: "#81a1c1",
90 KeywordNamespace: "#81a1c1",
91 KeywordReserved: "#81a1c1 bold",
92 KeywordType: "#88c0d0",
93 NameBuiltin: "#88c0d0",
94 NameFunction: "#8fbcbb",
95 NameClass: "#a3be8c bold",
96 NameDecorator: "#b48ead",
97 LiteralString: "#a3be8c",
98 LiteralStringEscape: "#bf616a",
99 LiteralNumber: "#d08770",
100 Comment: "#616e88 italic",
101 CommentPreproc: "#b48ead",
102 Operator: "#eceff4",
103 Punctuation: "#d8dee9",
104 Text: "#e5e9f0",
105}
106
107var defaultSpinnerFrames = []string{
108 "⠋",
109 "⠙",
110 "⠚",
111 "⠞",
112 "⠖",
113 "⠦",
114 "⠴",
115 "⠲",
116 "⠳",
117 "⠓",
118}
119
120var defaultSpinnerMessages = []string{
121 "thinking",
122 "reticulating splines",
123 "bribing the tokenizer",
124 "asking the rubber duck",
125 "summoning daemons",
126 "consulting the oracle",
127 "herding tokens",
128 "compiling excuses",
129 "poking the model",
130 "negotiating with rate limits",
131 "picking a fight with syntax",
132 "reading between the bits",
133 "tasting the semicolons",
134 "pretending to understand the code",
135 "petting the cache",
136 "drafting clever replies",
137 "warming up the GPU choir",
138 "arguing with a stack trace",
139 "googling the answer (not really)",
140 "rewriting history",
141 "every draft is a stone in the work",
142 "bringing order to the unhewn",
143 "finding the load-bearing measure",
144 "where clarity grows, work grows lighter",
145 "every correction serves the work",
146}
147
148var Dark = Theme{
149 FG: 253,
150 Muted: 244,
151 Accent: 111, // soft blue
152 User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
153 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
154 UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
155 Assistant: 117, // bright cyan — the zot label color
156 Tool: 114, // green
157 ToolOut: 245,
158 Error: 203,
159 Warning: 214,
160 Spinner: 183, // soft purple
161 SelectionBG: 24, // deep blue background
162 SelectionFG: 231, // near-white foreground
163 SpinnerFrames: defaultSpinnerFrames,
164 SpinnerMessages: defaultSpinnerMessages,
165 SpinnerIntervalMS: 80,
166 SyntaxBaseStyle: "monokai",
167 Syntax: nordSyntax,
168}
169
170var Light = Theme{
171 FG: 236,
172 Muted: 244,
173 Accent: 33,
174 User: 94,
175 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
176 UserBubbleFG: 240, // dark grey text, legible on the pale panel
177 Assistant: 31, // deep cyan
178 Tool: 28,
179 ToolOut: 240,
180 Error: 160,
181 Warning: 166,
182 Spinner: 91, // purple
183 SelectionBG: 153, // light blue
184 SelectionFG: 232, // near-black
185 SpinnerFrames: defaultSpinnerFrames,
186 SpinnerMessages: defaultSpinnerMessages,
187 SpinnerIntervalMS: 80,
188 SyntaxBaseStyle: "monokai",
189 Syntax: nordSyntax,
190}
191
192// FG256 wraps s in foreground color c using ANSI 256-color SGR.
193func (t Theme) FG256(c int, s string) string {
194 return sgrFG(c) + s + reset
195}
196
197// BG256 wraps s in background color c using ANSI 256-color SGR.
198// Useful when the visible cell needs a coloured fill but the
199// underlying character should be a regular space (so mouse-copy
200// from the terminal yields whitespace instead of a glyph).
201func (t Theme) BG256(c int, s string) string {
202 return sgrBG(c) + s + reset
203}
204
205// BG wraps s in a terminal background colour. Unlike BG256, this can
206// target ANSI theme slots or truecolor RGB, which lets selected UI
207// surfaces follow the user's terminal theme.
208func (t Theme) BG(c TerminalColor, s string) string {
209 return sgrBGColor(c) + s + reset
210}
211
212// AccentBar returns a 2-cell-wide leader: a coloured half-block
213// glyph followed by a plain space gutter. Used as the speaker-label
214// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
215// the bar reads consistently across the UI.
216func (t Theme) AccentBar(c int) string {
217 return t.FG256(c, "▌ ")
218}
219
220// Highlight paints s with the theme's selection colors (foreground +
221// background). The caller is responsible for padding s to the desired
222// width; styling alone does not extend the background past content.
223func (t Theme) Highlight(s string) string {
224 return t.SelectionStyle() + s + reset
225}
226
227// SelectionStyle returns the SGR prefix for the theme's selected row.
228func (t Theme) SelectionStyle() string {
229 return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
230}
231
232// SelectionStyleFG returns the SGR prefix for selected-row text with
233// a custom foreground. Useful for preserving semantic marks inside a
234// highlighted row without hardcoding escape sequences outside theme.go.
235func (t Theme) SelectionStyleFG(fg int) string {
236 return sgrFG(fg) + sgrBG(t.SelectionBG)
237}
238
239// PadHighlight styles s and extends the selection background to the
240// full terminal width so the highlight is a full row, not just a
241// rectangle around the text.
242func (t Theme) PadHighlight(s string, width int) string {
243 visible := visibleWidth(s)
244 if visible < width {
245 s += strings.Repeat(" ", width-visible)
246 }
247 return t.SelectionStyle() + s + reset
248}
249
250// UserBubble paints a single user message row with the bubble
251// background colour, padding to width so the tint extends to the
252// full terminal width. Foreground stays in UserBubbleFG so text
253// remains legible against the tint.
254func (t Theme) UserBubble(s string, width int) string {
255 visible := visibleWidth(s)
256 if visible < width {
257 s += strings.Repeat(" ", width-visible)
258 }
259 return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset
260}
261
262// UserBubbleRow renders one user-bubble row prefixed with a coloured
263// half-block accent bar ("▌ ") so every line of the bubble has the
264// zot-blue gutter at the very left. The bar lives outside the bubble
265// tint (chat bg) so the bubble itself sits inside it. Width is the
266// outer width including the bar; the bubble content is padded to
267// width-2 (the bar + its trailing space).
268func (t Theme) UserBubbleRow(content string, width int) string {
269 // Bar plus a single space gutter, in the assistant accent colour
270 // so it matches the tool-box / app accent and reads as zot's voice
271 // marker. Two cells wide.
272 bar := t.FG256(t.Assistant, "▌ ")
273 bubbleW := width - 2
274 if bubbleW < 1 {
275 bubbleW = 1
276 }
277 return bar + t.UserBubble(content, bubbleW)
278}
279
280// Bold wraps s in bold SGR.
281func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
282
283// Dim wraps s in dim SGR.
284func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" }
285
286// Italic wraps s in italic SGR.
287func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
288
289const reset = "\x1b[0m"
290
291func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }
292func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }
293func sgrBGColor(c TerminalColor) string {
294 switch c.Mode {
295 case terminalColorANSI:
296 return "\x1b[" + itoa(c.Index) + "m"
297 case terminalColorRGB:
298 return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"
299 default:
300 return sgrBG(c.Index)
301 }
302}
303
304// small itoa to avoid pulling strconv into this hot path twice.
305func itoa(n int) string {
306 if n == 0 {
307 return "0"
308 }
309 buf := [4]byte{}
310 i := len(buf)
311 for n > 0 {
312 i--
313 buf[i] = byte('0' + n%10)
314 n /= 10
315 }
316 return string(buf[i:])
317}