mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.
Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.
The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
* fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
directly instead of a raw substring, so a wrong-key regression fails.
228 lines
10 KiB
Go
228 lines
10 KiB
Go
package tui
|
||
|
||
import (
|
||
"image/color"
|
||
|
||
"charm.land/lipgloss/v2"
|
||
)
|
||
|
||
// tuiTheme is the resolved terminal palette Zero renders with. It is produced by
|
||
// buildTheme from a palette, so the same renderers serve both the dark default
|
||
// and the light variant; the active theme lives in the package var zeroTheme and
|
||
// may be swapped at startup (background detection / ZERO_THEME / --theme) or live
|
||
// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays
|
||
// and renders plain text when there is no TTY (tests). Every renderer consumes
|
||
// these named styles — no hex literal may appear outside theme_palettes.go (the
|
||
// palette tables + theme registry).
|
||
type tuiTheme struct {
|
||
// Base tokens.
|
||
ink lipgloss.Style // primary text
|
||
muted lipgloss.Style // secondary text, assistant interim prose
|
||
faint lipgloss.Style // hints, metadata
|
||
faintest lipgloss.Style // line numbers, separators, tool args
|
||
accent lipgloss.Style // brand lime: prompts, spinner, focus
|
||
green lipgloss.Style // success, diff add sign, ✓
|
||
red lipgloss.Style // errors, diff del sign, ✗, deny
|
||
amber lipgloss.Style // permission surfaces, warnings, auto badge
|
||
blue lipgloss.Style // grep file locations, local-model dot
|
||
gitAdd lipgloss.Style // PR/local diff additions
|
||
gitDel lipgloss.Style // PR/local diff deletions
|
||
line lipgloss.Style // default borders, rules, status separators
|
||
lineStrong lipgloss.Style // emphasized borders
|
||
selection lipgloss.Style // transcript selection highlight
|
||
hover lipgloss.Style // hovered clickable row (specialist card, toggle, sidebar/plan row)
|
||
|
||
// Title bar.
|
||
badge lipgloss.Style // ` 0 ` brand chip: onAccent on accent, bold
|
||
|
||
// Stream roles.
|
||
userPrompt lipgloss.Style // ❯ user gutter, accent bold
|
||
sayText lipgloss.Style // assistant interim prose, muted
|
||
|
||
// Tool cards.
|
||
toolName lipgloss.Style // head-row tool name, ink bold
|
||
toolTarget lipgloss.Style // head-row target path, ink
|
||
toolArg lipgloss.Style // one-line arg hint, faintest
|
||
cardRun lipgloss.Style // card border while the call runs (accent-mixed)
|
||
cardErr lipgloss.Style // card border after an error (red-mixed)
|
||
bashPrompt lipgloss.Style // ❯ command gutter inside bash cards, accent bold
|
||
grepLoc lipgloss.Style // file:line locations in grep bodies, blue
|
||
|
||
// Diff bodies. The sign/count styles are bare foregrounds; the line styles
|
||
// carry the tinted backgrounds standing in for the prototype's 9% overlays.
|
||
// Gutter and sign columns get their own bg-carrying styles because lipgloss
|
||
// resets the background between adjacent Render calls — every segment of a
|
||
// tinted row must carry the tint itself.
|
||
diffAdd lipgloss.Style // + sign in counts
|
||
diffDel lipgloss.Style // − sign in counts
|
||
diffMeta lipgloss.Style // @@ hunks, +++/--- headers
|
||
addLine lipgloss.Style // added-line text: addInk on addBg
|
||
delLine lipgloss.Style // deleted-line text: delInk on delBg
|
||
addLineWord lipgloss.Style // the changed span within an added line: addInk on the brighter addBgWord
|
||
delLineWord lipgloss.Style // the changed span within a deleted line: delInk on the brighter delBgWord
|
||
addLineNum lipgloss.Style // gutter number on addBg
|
||
delLineNum lipgloss.Style // gutter number on delBg
|
||
addSign lipgloss.Style // + column on addBg
|
||
delSign lipgloss.Style // − column on delBg
|
||
delText lipgloss.Style // delInk as bare foreground (stderr-ish output)
|
||
|
||
// Permission surfaces.
|
||
permBadge lipgloss.Style // PERMISSION chip: onAccent on amber, bold
|
||
permBg lipgloss.Style // permission card body tint
|
||
permBorder lipgloss.Style // permission card border (amber-mixed line)
|
||
|
||
// Surfaces.
|
||
panel lipgloss.Style // bare panel background (card padding, body fill)
|
||
userPromptPanel lipgloss.Style // submitted user prompt background
|
||
|
||
// Permission modes.
|
||
modeAuto lipgloss.Style
|
||
modeAsk lipgloss.Style
|
||
modeUnsafe lipgloss.Style
|
||
|
||
// Raw colors a few renderers paint/interpolate with directly (the streaming
|
||
// fade interpolates accent→ink; panel-backed prompts paint on bgPanel), kept
|
||
// on the theme so a theme switch reaches them too. The bg* colors back the
|
||
// on* surface helpers below.
|
||
accentColor color.Color
|
||
inkColor color.Color
|
||
bgPanel color.Color
|
||
bgPrompt color.Color
|
||
bgSel color.Color
|
||
bgPerm color.Color
|
||
}
|
||
|
||
// palette is the raw color-token table for one theme. buildTheme turns it into a
|
||
// resolved tuiTheme. The palette literals and the ordered theme registry live in
|
||
// theme_palettes.go (the only place hex literals live). Dark palettes keep tints
|
||
// darker than ink so every pairing survives 256-color downsampling; light palettes
|
||
// are dark-on-light with the same intent inverted.
|
||
type palette struct {
|
||
panel string // card backgrounds (the terminal canvas itself is never painted full-bleed)
|
||
promptBg string // submitted user prompt background
|
||
line string // default borders, rules
|
||
line2 string // emphasized borders
|
||
ink string // primary text
|
||
muted string // secondary text
|
||
faint string // hints, metadata
|
||
faintest string // line numbers, separators, tool args
|
||
accent string // brand lime
|
||
green string // success, diff add
|
||
red string // errors, diff del
|
||
amber string // permission, warnings
|
||
blue string // grep locations, local-model dot
|
||
gitAdd string // footer PR diff additions
|
||
gitDel string // footer PR diff deletions
|
||
addBg string // diff added-line bg
|
||
delBg string // diff deleted-line bg
|
||
addBgWord string // diff added-line changed-span bg (brighter than addBg)
|
||
delBgWord string // diff deleted-line changed-span bg (brighter than delBg)
|
||
permBg string // permission card bg
|
||
selBg string // selected row bg
|
||
addInk string // added-line text
|
||
delInk string // deleted-line text
|
||
onAccent string // text on accent or amber fills
|
||
cardRun string // running card border (accent mixed into line)
|
||
cardErr string // errored card border (red mixed into line)
|
||
cardPerm string // permission card border (amber mixed into line)
|
||
}
|
||
|
||
// buildTheme resolves a palette into the styles every renderer uses.
|
||
func buildTheme(p palette) tuiTheme {
|
||
col := func(s string) color.Color { return lipgloss.Color(s) }
|
||
fg := func(s string) lipgloss.Style { return lipgloss.NewStyle().Foreground(col(s)) }
|
||
return tuiTheme{
|
||
ink: fg(p.ink),
|
||
muted: fg(p.muted),
|
||
faint: fg(p.faint),
|
||
faintest: fg(p.faintest),
|
||
accent: fg(p.accent).Bold(true),
|
||
green: fg(p.green),
|
||
red: fg(p.red),
|
||
amber: fg(p.amber),
|
||
blue: fg(p.blue),
|
||
gitAdd: fg(p.gitAdd),
|
||
gitDel: fg(p.gitDel),
|
||
line: fg(p.line),
|
||
lineStrong: fg(p.line2),
|
||
selection: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)),
|
||
// A full block (like selection) would be too heavy for something that
|
||
// repaints on every mouse movement; a soft foreground reads as
|
||
// "interactive" at a glance without competing with real content colors
|
||
// (e.g. a specialist card's red/green status glyph). Amber (not the brand
|
||
// lime accent) — lime read as too glaring for something that repaints
|
||
// continuously as the cursor moves.
|
||
hover: fg(p.amber),
|
||
|
||
badge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true),
|
||
|
||
userPrompt: fg(p.accent).Bold(true),
|
||
sayText: fg(p.muted),
|
||
toolName: fg(p.ink).Bold(true),
|
||
toolTarget: fg(p.ink),
|
||
toolArg: fg(p.faintest),
|
||
cardRun: fg(p.cardRun),
|
||
cardErr: fg(p.cardErr),
|
||
bashPrompt: fg(p.accent).Bold(true),
|
||
grepLoc: fg(p.blue),
|
||
|
||
diffAdd: fg(p.green),
|
||
diffDel: fg(p.red),
|
||
diffMeta: fg(p.faintest),
|
||
addLineWord: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBgWord)),
|
||
delLineWord: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBgWord)),
|
||
addLine: lipgloss.NewStyle().Foreground(col(p.addInk)).Background(col(p.addBg)),
|
||
delLine: lipgloss.NewStyle().Foreground(col(p.delInk)).Background(col(p.delBg)),
|
||
addLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.addBg)),
|
||
delLineNum: lipgloss.NewStyle().Foreground(col(p.faintest)).Background(col(p.delBg)),
|
||
addSign: lipgloss.NewStyle().Foreground(col(p.green)).Background(col(p.addBg)),
|
||
delSign: lipgloss.NewStyle().Foreground(col(p.red)).Background(col(p.delBg)),
|
||
delText: fg(p.delInk),
|
||
|
||
permBadge: lipgloss.NewStyle().Background(col(p.amber)).Foreground(col(p.onAccent)).Bold(true),
|
||
permBg: lipgloss.NewStyle().Background(col(p.permBg)),
|
||
permBorder: fg(p.cardPerm),
|
||
|
||
panel: lipgloss.NewStyle().Background(col(p.panel)),
|
||
userPromptPanel: lipgloss.NewStyle().Background(col(p.promptBg)),
|
||
|
||
modeAuto: fg(p.green).Bold(true),
|
||
// The steady "ask" footer label is calm (un-bolded) so it doesn't compete
|
||
// with the transient bold-amber permission badge (permBadge) shown when a
|
||
// tool is actually asking right now — a glance separates state from event.
|
||
modeAsk: fg(p.amber),
|
||
modeUnsafe: fg(p.red).Bold(true),
|
||
|
||
accentColor: col(p.accent),
|
||
inkColor: col(p.ink),
|
||
bgPanel: col(p.panel),
|
||
bgPrompt: col(p.promptBg),
|
||
bgSel: col(p.selBg),
|
||
bgPerm: col(p.permBg),
|
||
}
|
||
}
|
||
|
||
// zeroTheme is the active palette every renderer reads. It defaults to dark and is
|
||
// reassigned by theme selection at startup and by /theme. All references are
|
||
// .field accesses evaluated at render time, so reassigning this var repaints every
|
||
// subsequent render.
|
||
var zeroTheme = buildTheme(darkPalette)
|
||
|
||
// onPanel returns a copy of style that paints on the panel surface. lipgloss
|
||
// resets the background between adjacent Render calls, so every segment of a
|
||
// panel row (including padding) must carry the background itself — renderers wrap
|
||
// their foreground styles through this instead of referencing hex.
|
||
func (t tuiTheme) onPanel(style lipgloss.Style) lipgloss.Style {
|
||
return style.Background(t.bgPanel)
|
||
}
|
||
|
||
// onSel paints on the selected-row tint.
|
||
func (t tuiTheme) onSel(style lipgloss.Style) lipgloss.Style {
|
||
return style.Background(t.bgSel)
|
||
}
|
||
|
||
// onPerm paints on the permission-card tint.
|
||
func (t tuiTheme) onPerm(style lipgloss.Style) lipgloss.Style {
|
||
return style.Background(t.bgPerm)
|
||
}
|