返回 DeepSeek-Reasonix
theme.go
根目录 / internal / cli / theme.go
1 package cli
2
3 import (
4 "fmt"
5 "image/color"
6 "log/slog"
7 "os"
8 "strconv"
9 "strings"
10
11 "charm.land/bubbles/v2/textarea"
12 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
14
15 "reasonix/internal/config"
16 "reasonix/internal/i18n"
17 )
18
19 type cliColor struct {
20 hex string
21 // Distance-based downsampling collapses the dark, low-chroma diff backgrounds
22 // to plain grey and loses the red/green tint that carries their meaning, so
23 // the 256-colour fallback stays hand-chosen rather than computed.
24 xterm int
25 }
26
27 type cliPalette struct {
28 name string
29 style string
30 accent cliColor
31 muted cliColor
32 faint cliColor
33 subtle cliColor
34 success cliColor
35 warn cliColor
36 err cliColor
37 danger cliColor
38 info cliColor
39 secondary cliColor
40 border cliColor
41 selection cliColor
42 userBubbleBG cliColor
43 diffAddBG cliColor
44 diffDelBG cliColor
45 toolRead cliColor
46 toolProc cliColor
47 }
48
49 type cliThemeStyle struct {
50 name string
51 mode string
52 accent cliColor
53 description string
54 }
55
56 var (
57 cliDarkTheme = cliPalette{
58 name: "dark",
59 style: "graphite",
60 accent: cliColor{"#d97757", 173},
61 muted: cliColor{"#c0c4cc", 251},
62 faint: cliColor{"#858b96", 245},
63 subtle: cliColor{"#a4a9b3", 248},
64 success: cliColor{"#74b87a", 108},
65 warn: cliColor{"#d9a441", 179},
66 err: cliColor{"#e0696a", 167},
67 danger: cliColor{"#e5484d", 167},
68 info: cliColor{"#56b6c2", 80},
69 secondary: cliColor{"#b18cff", 141},
70 border: cliColor{"#343945", 237},
71 selection: cliColor{"#d97757", 173},
72 userBubbleBG: cliColor{"#222631", 235},
73 diffAddBG: cliColor{"#14351d", 22},
74 diffDelBG: cliColor{"#3a1619", 52},
75 toolRead: cliColor{"#56b6c2", 80},
76 toolProc: cliColor{"#c678dd", 176},
77 }
78 cliLightTheme = cliPalette{
79 name: "light",
80 style: "sandstone",
81 accent: cliColor{"#2f5fa8", 25},
82 muted: cliColor{"#555049", 239},
83 faint: cliColor{"#82796f", 243},
84 subtle: cliColor{"#6f675f", 241},
85 success: cliColor{"#5d9b66", 65},
86 warn: cliColor{"#b68120", 136},
87 err: cliColor{"#b94b4d", 131},
88 danger: cliColor{"#e5484d", 167},
89 info: cliColor{"#2f5fa8", 25},
90 secondary: cliColor{"#7d63c8", 104},
91 border: cliColor{"#ded4c6", 252},
92 selection: cliColor{"#6f91d9", 68},
93 userBubbleBG: cliColor{"#f5f0e8", 255},
94 diffAddBG: cliColor{"#e5f3e7", 254},
95 diffDelBG: cliColor{"#fae8e8", 255},
96 toolRead: cliColor{"#6f91d9", 68},
97 toolProc: cliColor{"#8a6bb8", 97},
98 }
99 cliThemeStyles = []cliThemeStyle{
100 {name: "graphite", mode: "dark", accent: cliColor{"#d97757", 173}, description: "warm clay accent"},
101 {name: "ember", mode: "dark", accent: cliColor{"#f06d38", 209}, description: "hot orange accent"},
102 {name: "aurora", mode: "dark", accent: cliColor{"#34c3a6", 79}, description: "cool teal accent"},
103 {name: "midnight", mode: "dark", accent: cliColor{"#b18cff", 141}, description: "quiet violet accent"},
104 {name: "sandstone", mode: "light", accent: cliColor{"#c2613f", 173}, description: "default warm light accent"},
105 {name: "porcelain", mode: "light", accent: cliColor{"#7d63c8", 104}, description: "soft violet light accent"},
106 {name: "linen", mode: "light", accent: cliColor{"#bd5d4d", 167}, description: "muted coral light accent"},
107 {name: "glacier", mode: "light", accent: cliColor{"#357fa8", 74}, description: "cool blue light accent"},
108 }
109 activeCLITheme = applyCLIThemeStyle(cliDarkTheme, cliThemeStyles[0])
110 // activeBackgroundProbe stays inert unless a caller that owns stdin opts in
111 // through withTerminalProbe; terminalProbe is what opting in installs.
112 activeBackgroundProbe = noTerminalBackground
113 terminalProbe = queryTerminalBackground
114 )
115
116 func noTerminalBackground() (terminalRGB, bool) { return terminalRGB{}, false }
117
118 // cliCursorShape is the active cursor shape for the textarea input, configured
119 // via [ui] cursor_shape. Defaults to the slim bar used by the chat composer.
120 var cliCursorShape = "bar"
121
122 func configureCLITheme(mode string) {
123 configureCLIThemeWithStyle(mode, "")
124 }
125
126 func configureCLIThemeWithStyle(mode, style string) {
127 if env := strings.TrimSpace(os.Getenv("REASONIX_THEME")); env != "" {
128 if st, ok := cliThemeStyleByName(env); ok {
129 mode = st.mode
130 style = st.name
131 } else {
132 mode = env
133 }
134 }
135 if env := strings.TrimSpace(os.Getenv("REASONIX_THEME_STYLE")); env != "" {
136 style = env
137 }
138 activeCLITheme = resolveCLIThemeWithStyle(mode, style)
139 refreshCLIStyles()
140 }
141
142 func resolveCLITheme(mode string) cliPalette {
143 return resolveCLIThemeWithStyle(mode, "")
144 }
145
146 func resolveCLIThemeWithStyle(mode, style string) cliPalette {
147 mode = strings.ToLower(strings.TrimSpace(mode))
148 if st, ok := cliThemeStyleByName(mode); ok {
149 return buildCLITheme(st.mode, st.name)
150 }
151 resolvedMode := resolveCLIThemeMode(mode)
152 st, ok := cliThemeStyleByName(style)
153 if !ok || st.mode != resolvedMode {
154 st = defaultCLIThemeStyle(resolvedMode)
155 }
156 return buildCLITheme(resolvedMode, st.name)
157 }
158
159 func resolveCLIThemeMode(mode string) string {
160 switch strings.ToLower(strings.TrimSpace(mode)) {
161 case "light":
162 return "light"
163 case "dark":
164 return "dark"
165 case "auto", "":
166 if rgb, ok := activeBackgroundProbe(); ok {
167 if rgb.looksLight() {
168 return "light"
169 }
170 return "dark"
171 }
172 if colorFGBGLooksLight() {
173 return "light"
174 }
175 return "dark"
176 default:
177 return "dark"
178 }
179 }
180
181 func buildCLITheme(mode, style string) cliPalette {
182 base := cliDarkTheme
183 if mode == "light" {
184 base = cliLightTheme
185 }
186 st, ok := cliThemeStyleByName(style)
187 if !ok || st.mode != base.name {
188 st = defaultCLIThemeStyle(base.name)
189 }
190 return applyCLIThemeStyle(base, st)
191 }
192
193 func applyCLIThemeStyle(base cliPalette, style cliThemeStyle) cliPalette {
194 base.style = style.name
195 base.accent = style.accent
196 base.selection = style.accent
197 return base
198 }
199
200 func cliThemeStyleByName(name string) (cliThemeStyle, bool) {
201 name = strings.ToLower(strings.TrimSpace(name))
202 for _, st := range cliThemeStyles {
203 if st.name == name {
204 return st, true
205 }
206 }
207 return cliThemeStyle{}, false
208 }
209
210 func defaultCLIThemeStyle(mode string) cliThemeStyle {
211 if mode == "light" {
212 for _, st := range cliThemeStyles {
213 if st.name == "sandstone" {
214 return st
215 }
216 }
217 }
218 return cliThemeStyles[0]
219 }
220
221 // withTerminalProbe resolves "auto" against a live OSC 11 query. Probing reads
222 // stdin in raw mode, so only a caller that owns stdin may opt in; everyone else
223 // gets the COLORFGBG fallback and never fights the TUI's input reader.
224 func withTerminalProbe(fn func()) {
225 prev := activeBackgroundProbe
226 activeBackgroundProbe = terminalProbe
227 defer func() { activeBackgroundProbe = prev }()
228 fn()
229 }
230
231 func setCLIThemeMode(mode string) cliPalette {
232 activeCLITheme = resolveCLIThemeWithStyle(mode, activeCLITheme.style)
233 refreshCLIStyles()
234 return activeCLITheme
235 }
236
237 func setCLIThemeStyle(name string) (cliPalette, bool) {
238 st, ok := cliThemeStyleByName(name)
239 if !ok {
240 return cliPalette{}, false
241 }
242 activeCLITheme = resolveCLIThemeWithStyle(st.mode, st.name)
243 refreshCLIStyles()
244 return activeCLITheme, true
245 }
246
247 type terminalRGB struct {
248 r int
249 g int
250 b int
251 }
252
253 func (c terminalRGB) looksLight() bool {
254 luma := 0.2126*float64(c.r) + 0.7152*float64(c.g) + 0.0722*float64(c.b)
255 return luma >= 150
256 }
257
258 func parseOSC11Response(s string) (terminalRGB, bool) {
259 idx := strings.Index(s, "]11;")
260 if idx < 0 {
261 return terminalRGB{}, false
262 }
263 payload := s[idx+len("]11;"):]
264 if end := strings.IndexByte(payload, '\a'); end >= 0 {
265 payload = payload[:end]
266 } else if end := strings.Index(payload, "\x1b\\"); end >= 0 {
267 payload = payload[:end]
268 }
269 payload = strings.TrimSpace(payload)
270 if strings.HasPrefix(payload, "#") {
271 r, g, b, ok := parseHexColor(payload)
272 return terminalRGB{r, g, b}, ok
273 }
274 for _, prefix := range []string{"rgb:", "rgba:"} {
275 if strings.HasPrefix(payload, prefix) {
276 return parseOSCColorTriplet(strings.TrimPrefix(payload, prefix))
277 }
278 }
279 return terminalRGB{}, false
280 }
281
282 func parseOSCColorTriplet(s string) (terminalRGB, bool) {
283 parts := strings.Split(s, "/")
284 if len(parts) < 3 {
285 return terminalRGB{}, false
286 }
287 r, okR := parseOSCColorComponent(parts[0])
288 g, okG := parseOSCColorComponent(parts[1])
289 b, okB := parseOSCColorComponent(parts[2])
290 return terminalRGB{r, g, b}, okR && okG && okB
291 }
292
293 func parseOSCColorComponent(s string) (int, bool) {
294 s = strings.TrimSpace(s)
295 if s == "" || len(s) > 4 {
296 return 0, false
297 }
298 v, err := strconv.ParseInt(s, 16, 64)
299 if err != nil {
300 return 0, false
301 }
302 max := int64(1)<<(4*len(s)) - 1
303 if max <= 0 {
304 return 0, false
305 }
306 return int(v * 255 / max), true
307 }
308
309 func colorFGBGLooksLight() bool {
310 parts := strings.Split(os.Getenv("COLORFGBG"), ";")
311 if len(parts) == 0 {
312 return false
313 }
314 bg, err := strconv.Atoi(parts[len(parts)-1])
315 return err == nil && (bg == 7 || bg == 15)
316 }
317
318 func fgSGR(c cliColor) string {
319 if trueColorTerminal() {
320 if r, g, b, ok := parseHexColor(c.hex); ok {
321 return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
322 }
323 }
324 return fmt.Sprintf("\033[38;5;%dm", c.xterm)
325 }
326
327 func bgSGR(c cliColor) string {
328 if trueColorTerminal() {
329 if r, g, b, ok := parseHexColor(c.hex); ok {
330 return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
331 }
332 }
333 return fmt.Sprintf("\033[48;5;%dm", c.xterm)
334 }
335
336 func parseHexColor(hex string) (int, int, int, bool) {
337 hex = strings.TrimPrefix(hex, "#")
338 if len(hex) != 6 {
339 return 0, 0, 0, false
340 }
341 r, errR := strconv.ParseUint(hex[0:2], 16, 8)
342 g, errG := strconv.ParseUint(hex[2:4], 16, 8)
343 b, errB := strconv.ParseUint(hex[4:6], 16, 8)
344 return int(r), int(g), int(b), errR == nil && errG == nil && errB == nil
345 }
346
347 func themeFg(c cliColor, s string) string {
348 return sgr(fgSGR(c), s)
349 }
350
351 // themeLipColor pre-resolves the fallback rather than handing lipgloss a 24-bit
352 // value: the bubbletea renderer would otherwise downsample it with the same
353 // distance metric the hand-chosen xterm indices exist to avoid.
354 func themeLipColor(c cliColor) color.Color {
355 if trueColorTerminal() {
356 return lipgloss.Color(c.hex)
357 }
358 return lipgloss.Color(strconv.Itoa(c.xterm))
359 }
360
361 func themeStyle(c cliColor) lipgloss.Style {
362 if !colorOn() {
363 return lipgloss.NewStyle()
364 }
365 return lipgloss.NewStyle().Foreground(themeLipColor(c))
366 }
367
368 func withThemeBorderFG(st lipgloss.Style, c cliColor) lipgloss.Style {
369 if !colorOn() {
370 return st
371 }
372 return st.BorderForeground(themeLipColor(c))
373 }
374
375 func modeTagStyle(background, foreground cliColor) lipgloss.Style {
376 st := lipgloss.NewStyle().Bold(true).Padding(0, 1)
377 if !colorOn() {
378 return st
379 }
380 return st.Background(themeLipColor(background)).Foreground(themeLipColor(foreground))
381 }
382
383 func init() {
384 refreshCLIStyles()
385 }
386
387 func refreshCLIStyles() {
388 inputBoxStyle = withThemeBorderFG(lipgloss.NewStyle().
389 Border(lipgloss.NormalBorder(), true, false, true, false), activeCLITheme.accent).
390 PaddingLeft(1)
391 todoPanelStyle = withThemeBorderFG(lipgloss.NewStyle().
392 Border(lipgloss.NormalBorder(), true, false, false, false), activeCLITheme.border).
393 PaddingLeft(1)
394 statusBlockStyle = themeStyle(activeCLITheme.faint)
395 workingStyle = themeStyle(activeCLITheme.faint)
396 compSelStyle = themeStyle(activeCLITheme.accent).Bold(true)
397 choicePanelStyle = withThemeBorderFG(lipgloss.NewStyle().
398 Border(lipgloss.NormalBorder(), true, false, true, false), activeCLITheme.accent).
399 PaddingLeft(1)
400 scrollThumbStyle = themeStyle(activeCLITheme.accent)
401 scrollTrackStyle = themeStyle(activeCLITheme.faint)
402 }
403
404 func applyTextareaTheme(ti *textarea.Model) {
405 plain := lipgloss.NewStyle()
406 weak := themeStyle(activeCLITheme.faint)
407 if !colorOn() {
408 weak = plain
409 }
410
411 styles := ti.Styles()
412 styles.Focused = textarea.StyleState{
413 Base: plain,
414 Text: plain,
415 CursorLine: plain,
416 CursorLineNumber: weak,
417 EndOfBuffer: weak,
418 LineNumber: weak,
419 Placeholder: weak,
420 Prompt: weak,
421 }
422 styles.Blurred = textarea.StyleState{
423 Base: plain,
424 Text: plain,
425 CursorLine: plain,
426 CursorLineNumber: weak,
427 EndOfBuffer: weak,
428 LineNumber: weak,
429 Placeholder: weak,
430 Prompt: weak,
431 }
432 if colorOn() {
433 styles.Cursor.Color = themeLipColor(activeCLITheme.accent)
434 } else {
435 styles.Cursor.Color = nil
436 }
437 switch cliCursorShape {
438 case "block":
439 styles.Cursor.Shape = tea.CursorBlock
440 case "underline":
441 styles.Cursor.Shape = tea.CursorUnderline
442 default:
443 styles.Cursor.Shape = tea.CursorBar
444 }
445 ti.SetStyles(styles)
446 }
447
448 func (m *chatTUI) runThemeSubcommand(input string) tea.Cmd {
449 args := tokenizeArgs(input)
450 if len(args) < 2 {
451 m.notice(i18n.M.ThemeHeader + "\n" + describeCLIThemes() + "\n" + i18n.M.ThemeHint)
452 return nil
453 }
454 name := strings.ToLower(args[1])
455 previous := activeCLITheme
456 var theme cliPalette
457 switch name {
458 case "auto", "light", "dark":
459 theme = setCLIThemeMode(name)
460 default:
461 next, ok := setCLIThemeStyle(name)
462 if !ok {
463 m.notice(fmt.Sprintf(i18n.M.ThemeUnknownFmt, name) + "\n" + describeCLIThemes())
464 return nil
465 }
466 theme = next
467 }
468 m.refreshRuntimeTheme()
469 m.notice(fmt.Sprintf(i18n.M.ThemeChangedFmt, theme.name, theme.style))
470
471 // Persist to user config so the choice survives restart.
472 m.persistTheme(name)
473 return m.startThemeSweep(previous, theme)
474 }
475
476 func (m *chatTUI) persistTheme(inputName string) {
477 path := config.UserConfigPath()
478 if path == "" {
479 return
480 }
481 // Serialize the load-modify-save against other in-process user-config
482 // editors so concurrent writers don't drop each other's fields.
483 unlock := config.LockUserConfigEdits()
484 defer unlock()
485 edit := config.LoadForEdit(path)
486 switch inputName {
487 case "auto", "light", "dark":
488 edit.UI.Theme = inputName
489 edit.UI.ThemeStyle = activeCLITheme.style
490 default:
491 edit.UI.Theme = activeCLITheme.name
492 edit.UI.ThemeStyle = inputName
493 }
494 if err := edit.SaveTo(path); err != nil {
495 slog.Warn("theme: failed to persist", "path", path, "err", err)
496 }
497 }
498
499 func (m *chatTUI) refreshRuntimeTheme() {
500 m.spinner.Style = themeStyle(activeCLITheme.accent)
501 applyTextareaTheme(&m.input)
502 }
503
504 func describeCLIThemes() string {
505 var b strings.Builder
506 fmt.Fprintf(&b, "%s auto · light · dark\n", dim("modes:"))
507 for _, st := range cliThemeStyles {
508 marker := " "
509 if st.name == activeCLITheme.style {
510 marker = accent("› ")
511 }
512 fmt.Fprintf(&b, "%s%-10s %s %s\n", marker, st.name, dim(st.mode), dim(st.description))
513 }
514 return strings.TrimRight(b.String(), "\n")
515 }
516
517 func (m *chatTUI) themeArgItems(val string) ([]compItem, int, bool) {
518 cmdEnd := strings.IndexAny(val, " \t")
519 if cmdEnd < 0 || val[:cmdEnd] != "/theme" {
520 return nil, 0, false
521 }
522 from := strings.LastIndexAny(val, " \t") + 1
523 prior := strings.Fields(val[:from])
524 if len(prior) != 1 {
525 return nil, from, true
526 }
527 cur := strings.ToLower(val[from:])
528 items := []struct {
529 label string
530 mode string
531 desc string
532 }{
533 {label: "auto", mode: "mode", desc: "detect terminal background"},
534 {label: "light", mode: "mode", desc: "force light shell"},
535 {label: "dark", mode: "mode", desc: "force dark shell"},
536 }
537 var out []compItem
538 for _, it := range items {
539 if cur != "" && !strings.HasPrefix(it.label, cur) {
540 continue
541 }
542 out = append(out, compItem{label: it.label, insert: it.label, hint: it.mode + " · " + it.desc})
543 }
544 for _, st := range cliThemeStyles {
545 if cur != "" && !strings.HasPrefix(st.name, cur) {
546 continue
547 }
548 hint := st.mode + " · " + st.description
549 if st.name == activeCLITheme.style {
550 hint = i18n.M.ArgThemeCurrent
551 }
552 out = append(out, compItem{label: st.name, insert: st.name, hint: hint})
553 }
554 return out, from, true
555 }
556
556 lines GO