"""Color themes for the Navi Code TUI.
The default dark theme uses the exact gnexus-ui-kit cyberpunk palette
(light Tokyo Night influence) found in webclient/vendor/gnexus-ui-kit.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
from textual.color import Color
from textual.theme import Theme as TextualTheme
@dataclass(frozen=True)
class Theme:
"""A complete color theme for the TUI."""
name: str
background: Color
surface: Color
panel: Color
border: Color
primary: Color
primary_dark: Color
secondary: Color
accent: Color
text: Color
text_muted: Color
text_dim: Color
success: Color
warning: Color
error: Color
info: Color
user_bubble: Color
assistant_bubble: Color
thinking: Color
tool_border: Color
tool_success: Color
tool_error: Color
status_online: Color
status_offline: Color
prompt_border: Color
selection: Color
link: Color
def css(self) -> dict[str, str]:
"""Return CSS variable dictionary for Textual."""
return {
"tui-background": self.background.hex,
"tui-surface": self.surface.hex,
"tui-panel": self.panel.hex,
"tui-border": self.border.hex,
"tui-primary": self.primary.hex,
"tui-primary-dark": self.primary_dark.hex,
"tui-secondary": self.secondary.hex,
"tui-accent": self.accent.hex,
"tui-text": self.text.hex,
"tui-text-muted": self.text_muted.hex,
"tui-text-dim": self.text_dim.hex,
"tui-success": self.success.hex,
"tui-warning": self.warning.hex,
"tui-error": self.error.hex,
"tui-info": self.info.hex,
"tui-user-bubble": self.user_bubble.hex,
"tui-assistant-bubble": self.assistant_bubble.hex,
"tui-thinking": self.thinking.hex,
"tui-tool-border": self.tool_border.hex,
"tui-tool-success": self.tool_success.hex,
"tui-tool-error": self.tool_error.hex,
"tui-status-online": self.status_online.hex,
"tui-status-offline": self.status_offline.hex,
"tui-prompt-border": self.prompt_border.hex,
"tui-selection": self.selection.hex,
"tui-link": self.link.hex,
}
def to_textual_theme(self) -> TextualTheme:
"""Return a registered Textual theme that installs our CSS variables."""
return TextualTheme(
name=self.name,
primary=self.primary.hex,
secondary=self.secondary.hex,
warning=self.warning.hex,
error=self.error.hex,
success=self.success.hex,
accent=self.accent.hex,
foreground=self.text.hex,
background=self.background.hex,
surface=self.surface.hex,
panel=self.panel.hex,
variables=self.css(),
)
def to_css_string(self) -> str:
"""Generate a Textual CSS string with all theme variables."""
lines = ["/* Auto-generated TUI theme */"]
for key, value in self.css().items():
lines.append(f"$ {key}: {value};")
return "\n".join(lines)
# ── gnexus-ui-kit palette (webclient/vendor/gnexus-ui-kit/src/scss/_palette-colors.scss) ─
# neutrals
# $color-black: #16161E
# $color-dark: #1F2335
# $color-grey: #414868
# prime neons
# $color-cyan: #7DCFFF
# $color-magenta: #FF00CC
# $color-hot-pink: #FF1492
# $color-electric-blue:#7AA2F7
# $color-orange: #FF9E64
# secondary accents
# $color-purple: #BB9AF7
# $color-indigo: #565F89
# $color-teal: #73DACA
# highlights
# $color-neon-yellow: #E0AF68
# $color-neon-green: #9ECE6A
# text tones
# $color-text-light: #C0CAF5
# $color-text-medium: #A9B1D6
# $color-text-dark: #787C99
# UI state
# $color-primary: $color-text-light
# $color-secondary: $color-electric-blue
# $color-accent: $color-orange
# $color-success: $color-neon-green
# $color-warning: $color-neon-yellow
# $color-error: #F7768E
# $color-info: $color-purple
GNEXUS_DARK = Theme(
name="gnexus-dark",
background=Color.parse("#16161E"),
surface=Color.parse("#1F2335"),
panel=Color.parse("#24283B"), # slightly lighter than surface for panels
border=Color.parse("#414868"),
primary=Color.parse("#C0CAF5"), # text-light, primary UI color
primary_dark=Color.parse("#A9B1D6"),
secondary=Color.parse("#7AA2F7"), # electric-blue
accent=Color.parse("#FF9E64"), # orange
text=Color.parse("#C0CAF5"),
text_muted=Color.parse("#A9B1D6"),
text_dim=Color.parse("#787C99"),
success=Color.parse("#9ECE6A"), # neon-green
warning=Color.parse("#E0AF68"), # neon-yellow
error=Color.parse("#F7768E"),
info=Color.parse("#BB9AF7"), # purple
user_bubble=Color.parse("#565F89"), # indigo
assistant_bubble=Color.parse("#1F2335"),
thinking=Color.parse("#787C99"),
tool_border=Color.parse("#FF9E64"),
tool_success=Color.parse("#9ECE6A"),
tool_error=Color.parse("#F7768E"),
status_online=Color.parse("#9ECE6A"),
status_offline=Color.parse("#F7768E"),
prompt_border=Color.parse("#7DCFFF"), # cyan highlight for input
selection=Color.parse("#FF00CC"), # magenta — active selection / cursor line
link=Color.parse("#73DACA"), # teal — URLs and references
)
GNEXUS_LIGHT = Theme(
name="gnexus-light",
background=Color.parse("#F5F6FA"),
surface=Color.parse("#FFFFFF"),
panel=Color.parse("#ECEEF5"),
border=Color.parse("#A9B1D6"),
primary=Color.parse("#1F2335"),
primary_dark=Color.parse("#16161E"),
secondary=Color.parse("#2B3A67"),
accent=Color.parse("#FF6C00"),
text=Color.parse("#16161E"),
text_muted=Color.parse("#414868"),
text_dim=Color.parse("#787C99"),
success=Color.parse("#3F8F2A"),
warning=Color.parse("#B45309"),
error=Color.parse("#C41E3A"),
info=Color.parse("#7C3AED"),
user_bubble=Color.parse("#DBEAFE"),
assistant_bubble=Color.parse("#FFFFFF"),
thinking=Color.parse("#787C99"),
tool_border=Color.parse("#B45309"),
tool_success=Color.parse("#3F8F2A"),
tool_error=Color.parse("#C41E3A"),
status_online=Color.parse("#3F8F2A"),
status_offline=Color.parse("#C41E3A"),
prompt_border=Color.parse("#7AA2F7"),
selection=Color.parse("#FF1492"), # hot-pink — active selection / cursor line
link=Color.parse("#0D9488"), # darker teal for readability on light background
)
_active_theme_name: str = "gnexus-dark"
def set_active_theme(name: str) -> None:
"""Mark the named theme as the currently active one."""
global _active_theme_name
_active_theme_name = name
def get_active_theme() -> Theme:
"""Return the currently active theme, falling back to the dark default."""
return ThemeRegistry.get(_active_theme_name)
class ThemeRegistry:
"""Store and resolve themes by name."""
_themes: ClassVar[dict[str, Theme]] = {}
@classmethod
def register(cls, theme: Theme) -> None:
cls._themes[theme.name] = theme
@classmethod
def get(cls, name: str) -> Theme:
if name not in cls._themes:
return cls._themes.get("gnexus-dark", GNEXUS_DARK)
return cls._themes[name]
@classmethod
def all(cls) -> list[str]:
return list(cls._themes.keys())
ThemeRegistry.register(GNEXUS_DARK)
ThemeRegistry.register(GNEXUS_LIGHT)