diff --git a/clients/terminal/tui/renderers/error.py b/clients/terminal/tui/renderers/error.py index 86774b3..e389a46 100644 --- a/clients/terminal/tui/renderers/error.py +++ b/clients/terminal/tui/renderers/error.py @@ -7,6 +7,8 @@ from rich.panel import Panel from rich.text import Text +from clients.terminal.tui.themes import get_active_theme + from .base import ContentRenderer @@ -17,11 +19,12 @@ return msg.get("type") == "error" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("message", "unknown error") return Panel( - Text(text, style="bold"), + Text(text, style=f"bold {theme.error.hex}"), title="error", title_align="left", - border_style="red", + border_style=theme.error.hex, box=ROUNDED, ) diff --git a/clients/terminal/tui/renderers/message.py b/clients/terminal/tui/renderers/message.py index 63520d0..6cfb4b2 100644 --- a/clients/terminal/tui/renderers/message.py +++ b/clients/terminal/tui/renderers/message.py @@ -7,9 +7,16 @@ from rich.panel import Panel from rich.text import Text +from clients.terminal.tui.themes import get_active_theme + from .base import ContentRenderer +def _hex_style(color) -> str: + """Return a Rich-compatible hex style string from a Textual Color.""" + return color.hex + + class UserMessageRenderer(ContentRenderer): """Render a user message bubble.""" @@ -17,12 +24,13 @@ return msg.get("type") == "user_message" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("content", "") return Panel( - Text(text, style="bright_white"), + Text(text, style=_hex_style(theme.text)), title="You", title_align="left", - border_style="blue", + border_style=_hex_style(theme.user_bubble), box=ROUNDED, ) @@ -34,11 +42,12 @@ return msg.get("type") == "assistant_message" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("content", "") return Panel( - Text(text, style="bright_white"), + Text(text, style=_hex_style(theme.text)), title="Navi", title_align="left", - border_style="green", + border_style=_hex_style(theme.assistant_bubble), box=ROUNDED, ) diff --git a/clients/terminal/tui/renderers/plain.py b/clients/terminal/tui/renderers/plain.py index 9b0f099..ffd36b2 100644 --- a/clients/terminal/tui/renderers/plain.py +++ b/clients/terminal/tui/renderers/plain.py @@ -5,6 +5,8 @@ from rich.console import RenderableType from rich.text import Text +from clients.terminal.tui.themes import get_active_theme + from .base import ContentRenderer @@ -15,5 +17,6 @@ return True def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("content", "") or str(msg) - return Text(text, style="bright_white") + return Text(text, style=theme.text.hex) diff --git a/clients/terminal/tui/renderers/thinking.py b/clients/terminal/tui/renderers/thinking.py index 5629f70..93e05e4 100644 --- a/clients/terminal/tui/renderers/thinking.py +++ b/clients/terminal/tui/renderers/thinking.py @@ -7,6 +7,8 @@ from rich.panel import Panel from rich.text import Text +from clients.terminal.tui.themes import get_active_theme + from .base import ContentRenderer @@ -17,11 +19,12 @@ return msg.get("type") == "thinking_block" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("content", "") return Panel( - Text(text, style="dim"), + Text(text, style=theme.text_dim.hex), title="thinking", title_align="left", - border_style="bright_black", + border_style=theme.thinking.hex, box=ROUNDED, ) diff --git a/clients/terminal/tui/renderers/tool.py b/clients/terminal/tui/renderers/tool.py index 2c1d51c..e1e4a82 100644 --- a/clients/terminal/tui/renderers/tool.py +++ b/clients/terminal/tui/renderers/tool.py @@ -10,6 +10,8 @@ from rich.panel import Panel from rich.text import Text +from clients.terminal.tui.themes import get_active_theme + from .base import ContentRenderer @@ -20,6 +22,7 @@ return msg.get("type") == "tool_started" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() tool = msg.get("tool", "?") args = msg.get("args") or {} title = f"→ {tool}" @@ -27,14 +30,14 @@ try: body = RichJSON(json.dumps(args)) except Exception: - body = Text(str(args), style="bright_black") + body = Text(str(args), style=theme.text_dim.hex) else: body = Text("") return Panel( body, title=title, title_align="left", - border_style="yellow", + border_style=theme.tool_border.hex, box=ROUNDED, ) @@ -46,16 +49,17 @@ return msg.get("type") == "tool_call" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() tool = msg.get("tool", "?") success = msg.get("success", True) result = msg.get("result") - color = "green" if success else "red" + color = theme.tool_success if success else theme.tool_error title = f"← {tool} {'✓' if success else '✗'}" - body = Text(str(result) if result is not None else "", style="bright_black") + body = Text(str(result) if result is not None else "", style=theme.text_dim.hex) return Panel( body, title=title, title_align="left", - border_style=color, + border_style=color.hex, box=ROUNDED, ) diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py index 9e3154b..706c7e4 100644 --- a/clients/terminal/tui/themes.py +++ b/clients/terminal/tui/themes.py @@ -10,6 +10,7 @@ from typing import ClassVar from textual.color import Color +from textual.theme import Theme as TextualTheme @dataclass(frozen=True) @@ -75,6 +76,23 @@ "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 */"] @@ -176,6 +194,20 @@ ) +_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.""" diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 1e89b3b..568b0cf 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -18,7 +18,7 @@ WsEvent, ) from clients.terminal.tui.permissions import PermissionEngine -from clients.terminal.tui.themes import ThemeRegistry +from clients.terminal.tui.themes import ThemeRegistry, set_active_theme from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusPanel from clients.terminal.tui.ws_bridge import WsBridge @@ -44,6 +44,9 @@ ) -> None: self._theme_name = theme_name super().__init__() + self._register_textual_themes() + self.theme = self._theme_name + set_active_theme(self._theme_name) self._chat_panel = ChatPanel() self._status_panel = StatusPanel() self._input_box = InputBox() @@ -72,10 +75,15 @@ self._apply_theme() self.run_worker(self._startup) + def _register_textual_themes(self) -> None: + """Register every Navi theme as a Textual theme so $tui-* variables resolve.""" + for name in ThemeRegistry.all(): + self.register_theme(ThemeRegistry.get(name).to_textual_theme()) + def _apply_theme(self) -> None: - theme = ThemeRegistry.get(self._theme_name) - for name, value in theme.css().items(): - self.styles.set_rule(name, value) + """Activate the selected theme and update global active theme state.""" + set_active_theme(self._theme_name) + self.theme = self._theme_name async def _startup(self) -> None: session_id = await self._resolve_session( diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index dbe34f6..78c2776 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -16,11 +16,16 @@ DEFAULT_CSS = """ ChatPanel { - border: solid $primary; + border: solid $tui-border; + background: $tui-surface; + color: $tui-text; padding: 0 1; height: 1fr; width: 2fr; } + ChatPanel Static { + color: $tui-text; + } """ def __init__(self) -> None: diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index d3de254..b2b54e5 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -16,13 +16,20 @@ InputBox { height: auto; min-height: 3; - border: heavy $primary; + border: heavy $tui-prompt-border; + background: $tui-surface; + color: $tui-text; padding: 0 1; } InputBox Input { height: auto; border: none; - background: $surface; + background: $tui-surface; + color: $tui-text; + } + InputBox .prompt-char { + color: $tui-prompt-border; + text-style: bold; } """ diff --git a/clients/terminal/tui/widgets/status_panel.py b/clients/terminal/tui/widgets/status_panel.py index d41e79e..6fad6a5 100644 --- a/clients/terminal/tui/widgets/status_panel.py +++ b/clients/terminal/tui/widgets/status_panel.py @@ -2,11 +2,12 @@ from __future__ import annotations -from __future__ import annotations - from textual.app import ComposeResult from textual.containers import Vertical from textual.widgets import Static +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme class StatusPanel(Vertical): @@ -14,11 +15,23 @@ DEFAULT_CSS = """ StatusPanel { - border: solid $primary-darken-2; + border: solid $tui-border; + background: $tui-panel; + color: $tui-text-muted; padding: 1; height: 1fr; width: 1fr; } + StatusPanel .title { + text-style: bold; + color: $tui-primary; + } + StatusPanel .connection { + text-style: bold; + } + StatusPanel .hint { + color: $tui-text-dim; + } """ def __init__(self) -> None: @@ -49,7 +62,12 @@ self._model.update(f"Model: {model}") def set_connection(self, connected: bool, detail: str = "") -> None: + theme = get_active_theme() if connected: - self._connection.update(f"[green]Connection: online[/green] {detail}") + self._connection.update( + Text(f"Connection: online {detail}", style=theme.status_online.hex) + ) else: - self._connection.update(f"[red]Connection: offline[/red] {detail}") + self._connection.update( + Text(f"Connection: offline {detail}", style=theme.status_offline.hex) + )