diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py new file mode 100644 index 0000000..f09557c --- /dev/null +++ b/clients/terminal/tui/themes.py @@ -0,0 +1,192 @@ +"""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 + + +@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 + + 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, + } + + 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 +) + + +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"), +) + + +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) diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index cf9c5ed..1e89b3b 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -18,6 +18,7 @@ WsEvent, ) from clients.terminal.tui.permissions import PermissionEngine +from clients.terminal.tui.themes import ThemeRegistry from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusPanel from clients.terminal.tui.ws_bridge import WsBridge @@ -25,13 +26,6 @@ class NaviCodeTui(App): """OpenCode-inspired terminal UI for Navi.""" - CSS = """ - Screen { align: center middle; } - NaviCodeTui { padding: 0; } - Header { height: 1; } - Footer { height: 1; } - """ - BINDINGS = [ ("ctrl+p", "command_palette", "Palette"), ("ctrl+x q", "quit", "Quit"), @@ -46,7 +40,9 @@ session_id: str | None = None, profile_id: str | None = None, new_session: bool = False, + theme_name: str = "gnexus-dark", ) -> None: + self._theme_name = theme_name super().__init__() self._chat_panel = ChatPanel() self._status_panel = StatusPanel() @@ -73,8 +69,14 @@ yield Footer() def on_mount(self) -> None: + self._apply_theme() self.run_worker(self._startup) + def _apply_theme(self) -> None: + theme = ThemeRegistry.get(self._theme_name) + for name, value in theme.css().items(): + self.styles.set_rule(name, value) + async def _startup(self) -> None: session_id = await self._resolve_session( self._requested_session_id,