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