diff --git a/README.md b/README.md index 41bb5decc..cb1f2198e 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ no code changes required. - Slash commands for login, model selection, sessions, compaction, export, theme, and more. - Project instructions from `AGENTS.md`, `.tau/`, and `.agents/` resources. -- User skills and prompt templates. +- User skills, prompt templates, and custom TUI themes. - Context accounting, manual compaction, and optional automatic compaction. - Provider-neutral event rendering for Rich, plain text, JSON, transcripts, and custom frontends. diff --git a/src/tau_coding/commands.py b/src/tau_coding/commands.py index e33982bd5..c090a2458 100644 --- a/src/tau_coding/commands.py +++ b/src/tau_coding/commands.py @@ -17,7 +17,6 @@ from tau_coding.system_prompt import ProjectContextFile from tau_coding.thinking import normalize_thinking_level -BUILTIN_TUI_THEME_NAMES = ("tau-dark", "tau-light", "high-contrast") LOGIN_PROVIDER_ALIASES = { "anthropic-api": ("anthropic", "api-key"), "anthropic-subscription": ("anthropic", "subscription"), @@ -673,9 +672,14 @@ def _theme_command(context: CommandContext) -> CommandResult: if not context.args: return CommandResult(handled=True, theme_picker_requested=True) + # Imported lazily so importing this module never pulls in `tau_coding.tui` + # (whose package __init__ imports Textual) until /theme actually executes. + from tau_coding.tui.themes import available_tui_theme_names + theme_name = context.args.strip() - if theme_name not in BUILTIN_TUI_THEME_NAMES: - themes = ", ".join(BUILTIN_TUI_THEME_NAMES) + available = available_tui_theme_names() + if theme_name not in available: + themes = ", ".join(available) return CommandResult( handled=True, message=f"Unknown theme: {theme_name}\nAvailable themes: {themes}", diff --git a/src/tau_coding/paths.py b/src/tau_coding/paths.py index 668d7e8e6..da1e4c2db 100644 --- a/src/tau_coding/paths.py +++ b/src/tau_coding/paths.py @@ -44,6 +44,11 @@ def user_prompts_dir(self) -> Path: """Return Tau's user-level prompt templates directory.""" return self.home / "prompts" + @property + def user_themes_dir(self) -> Path: + """Return Tau's user-level TUI themes directory.""" + return self.home / "themes" + @property def user_agents_skills_dir(self) -> Path: """Return the user-level `.agents/skills` directory.""" @@ -70,6 +75,10 @@ def project_prompts_dir(self, cwd: Path) -> Path: """Return the project-local Tau prompt templates directory.""" return self.project_tau_dir(cwd) / "prompts" + def project_themes_dir(self, cwd: Path) -> Path: + """Return the project-local Tau TUI themes directory.""" + return self.project_tau_dir(cwd) / "themes" + def project_agents_skills_dir(self, cwd: Path) -> Path: """Return the project-local `.agents/skills` directory.""" return self.project_agents_dir(cwd) / "skills" diff --git a/src/tau_coding/resources.py b/src/tau_coding/resources.py index 080adf674..d5daa37df 100644 --- a/src/tau_coding/resources.py +++ b/src/tau_coding/resources.py @@ -80,6 +80,19 @@ def skills_dirs(self) -> tuple[Path, ...]: ) return tuple(_dedupe_paths(dirs)) + @property + def themes_dirs(self) -> tuple[Path, ...]: + """Return TUI theme directories in increasing precedence order. + + Themes are Tau-specific, so unlike skills and prompts no ``.agents`` + directories are scanned. + """ + paths = self._paths() + dirs = [self.root / "themes"] + if self.cwd is not None: + dirs.append(paths.project_themes_dir(self.cwd)) + return tuple(_dedupe_paths(dirs)) + @property def prompts_dirs(self) -> tuple[Path, ...]: """Return prompt template directories in increasing precedence order.""" diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 1d5b4420c..307c289df 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -109,6 +109,7 @@ upsert_saved_provider, ) from tau_coding.provider_runtime import create_model_provider +from tau_coding.resources import TauResourcePaths from tau_coding.session import ( TREE_RUNNING_MESSAGE, CodingSession, @@ -129,7 +130,6 @@ build_completion_state, ) from tau_coding.tui.config import ( - BUILTIN_TUI_THEME_NAMES, TAU_DARK_THEME, TuiKeybindings, TuiSettings, @@ -140,6 +140,12 @@ ) from tau_coding.tui.state import TuiState, format_terminal_command_result_block from tau_coding.tui.terminal_title import TerminalTitleController +from tau_coding.tui.themes import ( + available_tui_theme_names, + get_tui_theme, + load_custom_tui_themes, + set_custom_tui_themes, +) from tau_coding.tui.widgets import ( CompactSessionInfo, SessionSidebar, @@ -1686,7 +1692,7 @@ def _move_cursor(self, *, offset: int) -> None: class ThemePickerScreen(ModalScreen[TuiThemeName | None]): - """Theme picker for the built-in TUI themes.""" + """Theme picker for the available TUI themes.""" BINDINGS: ClassVar[list[BindingEntry]] = [ Binding("escape", "cancel", "Cancel", priority=True), @@ -1695,10 +1701,17 @@ class ThemePickerScreen(ModalScreen[TuiThemeName | None]): Binding("enter", "select_cursor", "Select", show=False, priority=True), ] - def __init__(self, *, current_theme: TuiThemeName, theme: TuiTheme) -> None: + def __init__( + self, + *, + current_theme: TuiThemeName, + theme: TuiTheme, + theme_names: tuple[TuiThemeName, ...], + ) -> None: super().__init__() self.current_theme = current_theme self.theme = theme + self.theme_names = theme_names def compose(self) -> ComposeResult: """Compose the theme picker.""" @@ -1712,7 +1725,7 @@ def compose(self) -> ComposeResult: markup=False, ) ) - for theme_name in BUILTIN_TUI_THEME_NAMES + for theme_name in self.theme_names ], id="theme-picker-list", ) @@ -1722,7 +1735,7 @@ def on_mount(self) -> None: """Select the current theme.""" theme_list = self.query_one("#theme-picker-list", ListView) try: - theme_list.index = BUILTIN_TUI_THEME_NAMES.index(self.current_theme) + theme_list.index = self.theme_names.index(self.current_theme) except ValueError: theme_list.index = 0 theme_list.focus() @@ -1741,7 +1754,7 @@ def on_key(self, event: Key) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None: """Dismiss with the selected theme name.""" - self.dismiss(BUILTIN_TUI_THEME_NAMES[event.index]) + self.dismiss(self.theme_names[event.index]) def action_cursor_up(self) -> None: """Move to the previous theme.""" @@ -2816,12 +2829,24 @@ def __init__( self.initial_prompt = initial_prompt super().__init__() self._register_tau_textual_themes() - self.theme = self.tui_settings.theme + # Assign the resolved theme's name: it is always registered, while the + # raw settings value may name a custom theme that failed to load. The + # guard keeps the watcher from persisting the fallback over the user's + # configured theme. + self._applying_settings_theme = True + self.theme = self.tui_settings.resolved_theme.name + self._applying_settings_theme = False self._bindings = BindingsMap(_app_bindings(self.tui_settings.keybindings)) self.session = session self.state = TuiState(skills=session.skills) for notice in self.startup_notices: self.state.add_item("status", notice) + if self.tui_settings.theme != self.tui_settings.resolved_theme.name: + self.state.add_item( + "status", + f"Theme '{self.tui_settings.theme}' was not found; " + f"using {self.tui_settings.resolved_theme.name}.", + ) self._prompt_history: tuple[str, ...] = () self._load_session_messages_from_session() self.adapter = TuiEventAdapter(self.state) @@ -2904,13 +2929,15 @@ def _register_tau_textual_themes(self) -> None: `/theme` instead of changing only Textual's chrome. """ self._registered_themes.clear() - for theme_name in BUILTIN_TUI_THEME_NAMES: + for theme_name in available_tui_theme_names(): self.register_theme(_textual_theme_for_tau_theme(theme_name)) def _watch_theme(self, theme_name: str) -> None: """Keep Textual theme changes synchronized with Tau's durable TUI theme.""" super()._watch_theme(theme_name) - if theme_name not in BUILTIN_TUI_THEME_NAMES: + if theme_name not in available_tui_theme_names(): + return + if getattr(self, "_applying_settings_theme", False): return tau_theme: TuiThemeName = theme_name if self.tui_settings.theme == tau_theme: @@ -3184,7 +3211,7 @@ async def _submit_prompt_from_editor( if command.thinking_level is not None: await self._set_thinking_level(command.thinking_level) if command.theme is not None: - self._set_tui_theme(cast(TuiThemeName, command.theme)) + self._set_tui_theme(command.theme) self.state.set_skills(self.session.skills) if command.message: if _command_message_uses_notification(text, command.message): @@ -3907,6 +3934,9 @@ def _replace_tui_settings(self, *, theme: TuiThemeName) -> None: ) def _set_tui_theme(self, theme: TuiThemeName) -> None: + if theme not in available_tui_theme_names(): + self._notify(f"Unknown theme: {theme}", severity="error") + return self._replace_tui_settings(theme=theme) save_tui_settings(self.tui_settings) self.theme = theme @@ -4803,6 +4833,7 @@ def _open_theme_picker(self) -> None: ThemePickerScreen( current_theme=self.tui_settings.theme, theme=self.tui_settings.resolved_theme, + theme_names=available_tui_theme_names(), ), callback=self._handle_theme_picker_result, ) @@ -5094,7 +5125,7 @@ def _build_completion_state(self, text: str) -> CompletionState: *LOGIN_PROVIDER_ALIASES, ), thinking_levels=getattr(self.session, "available_thinking_levels", ()), - theme_names=BUILTIN_TUI_THEME_NAMES, + theme_names=available_tui_theme_names(), session_options=_session_options(self.session), cwd=self.session.cwd, ) @@ -5554,20 +5585,20 @@ def _is_thinking_cycle_key(key: str, configured_key: str) -> bool: def _textual_theme_for_tau_theme(theme_name: TuiThemeName) -> Theme: """Map a Tau theme to Textual's native theme type.""" - theme = TuiSettings(theme=theme_name).resolved_theme + theme = get_tui_theme(theme_name) return Theme( name=theme.name, primary=theme.accent, secondary=theme.prompt_border, warning=theme.markdown_bullet, - error=theme.role_styles["error"].border, - success=theme.role_styles["assistant"].border, + error=theme.error, + success=theme.success, accent=theme.accent, foreground=theme.screen_text, background=theme.screen_background, surface=theme.chrome_background, panel=theme.sidebar_background, - dark=theme.name != "tau-light", + dark=theme.dark, variables=_theme_css_variables(theme), ) @@ -6005,9 +6036,16 @@ async def run_tui_app( project_extensions_enabled=project_extensions_enabled, ) ) + custom_themes, theme_diagnostics = load_custom_tui_themes( + TauResourcePaths(cwd=record.cwd).themes_dirs + ) + set_custom_tui_themes(custom_themes) legacy_notices = (startup_notice,) if startup_notice else () error_notices = (startup_error_notice,) if startup_error_notice else () - all_startup_notices = tuple((*error_notices, *startup_notices, *legacy_notices)) + theme_notices = tuple(diagnostic.format() for diagnostic in theme_diagnostics) + all_startup_notices = tuple( + (*error_notices, *startup_notices, *legacy_notices, *theme_notices) + ) app = TauTuiApp( session, tui_settings=load_tui_settings(), diff --git a/src/tau_coding/tui/config.py b/src/tau_coding/tui/config.py index df5950d66..0dbabb10f 100644 --- a/src/tau_coding/tui/config.py +++ b/src/tau_coding/tui/config.py @@ -8,6 +8,34 @@ from typing import Any, Literal, cast from tau_coding.paths import TauPaths +from tau_coding.tui.themes import ( + BUILTIN_TUI_THEME_NAMES, + HIGH_CONTRAST_THEME, + TAU_DARK_THEME, + TAU_LIGHT_THEME, + TuiRoleStyle, + TuiTheme, + TuiThemeName, + get_tui_theme, +) + +__all__ = [ + "BUILTIN_TUI_THEME_NAMES", + "HIGH_CONTRAST_THEME", + "TAU_DARK_THEME", + "TAU_LIGHT_THEME", + "TuiConfigError", + "TuiKeybindings", + "TuiRoleStyle", + "TuiSettings", + "TuiTheme", + "TuiThemeName", + "get_tui_theme", + "load_tui_settings", + "save_tui_settings", + "tui_settings_from_json", + "tui_settings_path", +] class TuiConfigError(ValueError): @@ -51,195 +79,6 @@ def to_json(self) -> dict[str, str]: } -type TuiThemeName = Literal["tau-dark", "tau-light", "high-contrast"] - - -@dataclass(frozen=True, slots=True) -class TuiRoleStyle: - """Colors for one transcript role block.""" - - border: str - body: str - - -@dataclass(frozen=True, slots=True) -class TuiTheme: - """Resolved visual theme for Tau's built-in Textual frontend.""" - - name: TuiThemeName - screen_background: str - screen_text: str - chrome_background: str - chrome_text: str - muted_text: str - sidebar_background: str - border: str - transcript_background: str - prompt_background: str - prompt_text: str - prompt_border: str - autocomplete_background: str - accent: str - highlight_background: str - highlight_text: str - markdown_heading: str - markdown_table_header: str - markdown_table_border: str - markdown_inline_code: str - markdown_code_block_background: str - markdown_link: str - markdown_bullet: str - completion_description: str - syntax_theme: str - role_styles: dict[str, TuiRoleStyle] - - @property - def completion_selected(self) -> str: - """Return the shared highlighted-row style used by autocomplete.""" - return f"bold {self.highlight_text} on {self.highlight_background}" - - @property - def completion_selected_description(self) -> str: - """Return the shared highlighted-row description style.""" - return f"{self.highlight_text} on {self.highlight_background}" - - -TAU_DARK_THEME = TuiTheme( - name="tau-dark", - screen_background="#000000", - screen_text="#d8dee9", - chrome_background="#000000", - chrome_text="#d8dee9", - muted_text="#667085", - sidebar_background="#000000", - border="#141922", - transcript_background="#000000", - prompt_background="#101419", - prompt_text="#e5e7eb", - prompt_border="#2d3748", - autocomplete_background="#000000", - accent="#a7f3f0", - highlight_background="#a7f3f0", - highlight_text="#061a1a", - markdown_heading="#a7f3f0", - markdown_table_header="#7b7b7b", - markdown_table_border="#7b7b7b", - markdown_inline_code="#759e95", - markdown_code_block_background="#161b21", - markdown_link="#93c5fd", - markdown_bullet="#a7f3f0", - completion_description="#667085", - syntax_theme="ansi_dark", - role_styles={ - "user": TuiRoleStyle(border="#7c8ea6", body="#d8dee9 on #101419"), - "assistant": TuiRoleStyle(border="#6ea6a0", body="#d8dee9 on #000000"), - "tool": TuiRoleStyle(border="#8a7a52", body="#cbd5e1 on #000000"), - "error": TuiRoleStyle(border="#ff4f4f", body="#ffb4b4 on #000000"), - "status": TuiRoleStyle(border="#526070", body="#aab4c2 on #000000"), - "thinking": TuiRoleStyle(border="#4b5563", body="#9ca3af on #000000"), - "skill": TuiRoleStyle(border="#b48ead", body="#e5d4ef on #000000"), - "custom": TuiRoleStyle(border="#6ea6a0", body="#d8dee9 on #000000"), - "branch_summary": TuiRoleStyle(border="#c084fc", body="#e9d5ff on #000000"), - "compaction_summary": TuiRoleStyle(border="#c084fc", body="#e9d5ff on #000000"), - }, -) - - -HIGH_CONTRAST_THEME = TuiTheme( - name="high-contrast", - screen_background="#000000", - screen_text="#ffffff", - chrome_background="#111111", - chrome_text="#ffffff", - muted_text="#d0d0d0", - sidebar_background="#111111", - border="#888888", - transcript_background="#000000", - prompt_background="#1a1a1a", - prompt_text="#ffffff", - prompt_border="#00ff66", - autocomplete_background="#111111", - accent="#ffb454", - highlight_background="#7fffd4", - highlight_text="#000000", - markdown_heading="#ffb454", - markdown_table_header="#d0d0d0", - markdown_table_border="#d0d0d0", - markdown_inline_code="#7fffd4", - markdown_code_block_background="#161b21", - markdown_link="#80d8ff", - markdown_bullet="#ffb454", - completion_description="white", - syntax_theme="ansi_dark", - role_styles={ - "user": TuiRoleStyle(border="#00b7ff", body="white on #1a1a1a"), - "assistant": TuiRoleStyle(border="#00ff66", body="white on #001a0b"), - "tool": TuiRoleStyle(border="#ffd000", body="white on #211900"), - "error": TuiRoleStyle(border="#ff4f4f", body="white on #260000"), - "status": TuiRoleStyle(border="#ffffff", body="white on #111111"), - "thinking": TuiRoleStyle(border="#00b7ff", body="white on #001626"), - "skill": TuiRoleStyle(border="#ff8cff", body="white on #260026"), - "custom": TuiRoleStyle(border="#00ffcc", body="white on #001a17"), - "branch_summary": TuiRoleStyle(border="#d8b4fe", body="white on #260026"), - "compaction_summary": TuiRoleStyle(border="#d8b4fe", body="white on #260026"), - }, -) - - -TAU_LIGHT_THEME = TuiTheme( - name="tau-light", - screen_background="#ffffff", - screen_text="#111827", - chrome_background="#f3f4f6", - chrome_text="#111827", - muted_text="#475569", - sidebar_background="#f8fafc", - border="#cbd5e1", - transcript_background="#ffffff", - prompt_background="#f8fafc", - prompt_text="#111827", - prompt_border="#2563eb", - autocomplete_background="#ffffff", - accent="#0f766e", - highlight_background="#dbeafe", - highlight_text="#1d4ed8", - markdown_heading="#b45309", - markdown_table_header="#64748b", - markdown_table_border="#cbd5e1", - markdown_inline_code="#0f766e", - markdown_code_block_background="#f1f5f9", - markdown_link="#2563eb", - markdown_bullet="#b45309", - completion_description="#667085", - syntax_theme="ansi_light", - role_styles={ - "user": TuiRoleStyle(border="#2563eb", body="#111827 on #f8fafc"), - "assistant": TuiRoleStyle(border="#0f766e", body="#111827"), - "tool": TuiRoleStyle(border="#a16207", body="#1f2937"), - "error": TuiRoleStyle(border="#b91c1c", body="#7f1d1d"), - "status": TuiRoleStyle(border="#64748b", body="#334155"), - "thinking": TuiRoleStyle(border="#6b7280", body="#4b5563"), - "skill": TuiRoleStyle(border="#7c3aed", body="#4c1d95"), - "custom": TuiRoleStyle(border="#0f766e", body="#111827"), - "branch_summary": TuiRoleStyle(border="#9333ea", body="#581c87"), - "compaction_summary": TuiRoleStyle(border="#9333ea", body="#581c87"), - }, -) - - -_THEMES: dict[TuiThemeName, TuiTheme] = { - TAU_DARK_THEME.name: TAU_DARK_THEME, - TAU_LIGHT_THEME.name: TAU_LIGHT_THEME, - HIGH_CONTRAST_THEME.name: HIGH_CONTRAST_THEME, -} -BUILTIN_TUI_THEME_NAMES: tuple[TuiThemeName, ...] = tuple(_THEMES) - - -def get_tui_theme(name: TuiThemeName = "tau-dark") -> TuiTheme: - """Return a built-in TUI theme by name.""" - return _THEMES[name] - - @dataclass(frozen=True, slots=True) class TuiSettings: """Tau TUI settings loaded from Tau home.""" @@ -260,8 +99,11 @@ def to_json(self) -> dict[str, Any]: @property def resolved_theme(self) -> TuiTheme: - """Return the selected built-in theme.""" - return get_tui_theme(self.theme) + """Return the selected theme, falling back to tau-dark when unknown.""" + try: + return get_tui_theme(self.theme) + except KeyError: + return TAU_DARK_THEME def tui_settings_path(paths: TauPaths | None = None) -> Path: @@ -343,10 +185,7 @@ def _key_string(value: object, field_name: str) -> str: def _theme_name(value: object) -> TuiThemeName: if not isinstance(value, str) or not value.strip(): raise TuiConfigError("TUI theme must be a non-empty string") - name = value.strip() - if name == "tau-dark" or name == "tau-light" or name == "high-contrast": - return cast(TuiThemeName, name) - raise TuiConfigError(f"Unknown TUI theme: {name}") + return value.strip() def _reject_duplicate_keys(values: dict[str, str]) -> None: diff --git a/src/tau_coding/tui/state.py b/src/tau_coding/tui/state.py index 95a072584..10fa24941 100644 --- a/src/tau_coding/tui/state.py +++ b/src/tau_coding/tui/state.py @@ -6,7 +6,6 @@ from collections.abc import Iterable from dataclasses import dataclass, field from pathlib import Path -from typing import Literal from tau_agent.messages import ( AgentMessage, @@ -23,19 +22,9 @@ from tau_agent.types import JSONValue from tau_coding.extensions.api import CustomMessageMarkup, ToolCallMarkup, ToolResultMarkup from tau_coding.skills import Skill, parse_skill_invocation +from tau_coding.tui.themes import TranscriptRole -ChatItemRole = Literal[ - "user", - "assistant", - "tool", - "error", - "status", - "thinking", - "skill", - "branch_summary", - "compaction_summary", - "custom", -] +ChatItemRole = TranscriptRole TOOL_RESULT_PREVIEW_LINES = 8 TOOL_PATCH_PREVIEW_LINES = 32 TOOL_RESULT_PREVIEW_CHARS = 2_000 diff --git a/src/tau_coding/tui/themes/__init__.py b/src/tau_coding/tui/themes/__init__.py new file mode 100644 index 000000000..a50398da1 --- /dev/null +++ b/src/tau_coding/tui/themes/__init__.py @@ -0,0 +1,459 @@ +"""JSON-defined TUI themes for Tau. + +Themes are data, not code. The built-in themes ship as JSON files next to this +module and load through the same parser as user themes, which live in +``~/.tau/themes/*.json`` and ``/.tau/themes/*.json``. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, fields +from functools import cache +from importlib.resources import files +from json import JSONDecodeError, loads +from pathlib import Path +from typing import Literal, get_args + +from rich.color import Color, ColorParseError +from rich.errors import StyleSyntaxError +from rich.style import Style +from textual.color import Color as TextualColor +from textual.color import ColorParseError as TextualColorParseError + +from tau_coding.resources import ResourceDiagnostic + + +class TuiThemeError(ValueError): + """Raised when a TUI theme definition is invalid.""" + + +@dataclass(frozen=True, slots=True) +class TuiRoleStyle: + """Colors for one transcript role block.""" + + border: str + body: str + + +@dataclass(frozen=True, slots=True) +class TuiTheme: + """Resolved visual theme for Tau's built-in Textual frontend.""" + + name: str + dark: bool + screen_background: str + screen_text: str + chrome_background: str + chrome_text: str + muted_text: str + sidebar_background: str + border: str + transcript_background: str + prompt_background: str + prompt_text: str + prompt_border: str + autocomplete_background: str + accent: str + success: str + error: str + tool_success_text: str + tool_error_text: str + highlight_background: str + highlight_text: str + markdown_heading: str + markdown_table_header: str + markdown_table_border: str + markdown_inline_code: str + markdown_code_block_background: str + markdown_link: str + markdown_bullet: str + completion_selected: str + completion_selected_description: str + completion_description: str + syntax_theme: str + role_styles: dict[str, TuiRoleStyle] + + +type TuiThemeName = str + +THEME_COLOR_FIELDS: tuple[str, ...] = tuple( + theme_field.name + for theme_field in fields(TuiTheme) + if theme_field.name not in {"name", "dark", "syntax_theme", "role_styles"} +) + +# Single source of truth for transcript roles: the theme schema requires a +# style for each, and tui.state aliases this as ChatItemRole. +TranscriptRole = Literal[ + "user", + "assistant", + "tool", + "error", + "status", + "thinking", + "skill", + "custom", + "branch_summary", + "compaction_summary", +] +TRANSCRIPT_ROLES: tuple[str, ...] = get_args(TranscriptRole) + +_TOP_LEVEL_FIELDS = {"$schema", "name", "dark", "vars", "syntax_theme", "colors", "roles"} + +# Fields that feed Textual CSS variables and must be a single color, unlike +# the completion fields and role bodies, which are full Rich style strings. +_RICH_STYLE_FIELDS = { + "completion_selected", + "completion_selected_description", + "completion_description", +} + +# Single-color fields only ever rendered through Rich. Every other color field +# reaches Textual too (CSS variables, Theme slots, or widget styles), and Rich +# accepts colors Textual rejects (bright_red, grey50, color(1), default), so +# those fields must parse under both libraries. New fields default to the +# strict dual check: a field missing from this set rejects a theme with a +# diagnostic instead of crashing the TUI when the theme is applied. +_RICH_ONLY_COLOR_FIELDS = { + "tool_success_text", + "tool_error_text", +} + +# Var names that would corrupt Rich style strings during token substitution. +_RICH_STYLE_KEYWORDS = frozenset( + { + "on", + "not", + "none", + "default", + "b", + "bold", + "d", + "dim", + "i", + "italic", + "u", + "underline", + "uu", + "underline2", + "s", + "strike", + "r", + "reverse", + "blink", + "blink2", + "conceal", + "o", + "overline", + "frame", + "encircle", + "link", + } +) + + +def parse_tui_theme_json(data: object) -> TuiTheme: + """Parse a theme from JSON-compatible data, reporting all problems at once.""" + if not isinstance(data, dict): + raise TuiThemeError("Theme must be a JSON object") + + problems: list[str] = [] + for key in sorted(set(data) - _TOP_LEVEL_FIELDS): + problems.append(f"unknown field: {key}") + + name = _parse_name(data.get("name"), problems) + variables = _parse_vars(data.get("vars", {}), problems) + colors = _parse_colors(data.get("colors"), variables, problems) + role_styles = _parse_roles(data.get("roles"), variables, problems) + dark = _parse_dark(data.get("dark"), colors, problems) + syntax_theme = _parse_syntax_theme(data.get("syntax_theme"), dark=dark, problems=problems) + + if problems: + label = f"theme {name!r}" if name else "theme" + raise TuiThemeError(f"Invalid {label}: " + "; ".join(problems)) + return TuiTheme( + name=name, + dark=dark, + syntax_theme=syntax_theme, + role_styles=role_styles, + **colors, + ) + + +def _parse_name(value: object, problems: list[str]) -> str: + if not isinstance(value, str) or not value.strip(): + problems.append("name must be a non-empty string") + return "" + name = value.strip() + if "/" in name: + problems.append("name must not contain '/'") + return name + + +def _parse_vars(value: object, problems: list[str]) -> dict[str, str]: + if not isinstance(value, dict): + problems.append("vars must be an object") + return {} + variables: dict[str, str] = {} + for var_name, var_value in value.items(): + name_allowed = isinstance(var_name, str) and var_name + if not name_allowed or var_name.lower() in _RICH_STYLE_KEYWORDS: + problems.append(f"vars name is not allowed: {var_name!r}") + continue + if ( + not isinstance(var_value, str) + or len(var_value.split()) != 1 + or _color_problem(var_value) is not None + ): + problems.append(f"vars value must be a single color: {var_name}") + continue + variables[var_name] = var_value + return variables + + +def _substitute_vars(value: str, variables: dict[str, str]) -> str: + if not variables: + return value + return " ".join(variables.get(token, token) for token in value.split()) + + +def _parse_colors( + value: object, + variables: dict[str, str], + problems: list[str], +) -> dict[str, str]: + if not isinstance(value, dict): + problems.append("colors must be an object") + return {} + missing = [field_name for field_name in THEME_COLOR_FIELDS if field_name not in value] + if missing: + problems.append("colors missing: " + ", ".join(missing)) + unknown = sorted(set(value) - set(THEME_COLOR_FIELDS)) + if unknown: + problems.append("colors unknown: " + ", ".join(unknown)) + colors: dict[str, str] = {} + for field_name in THEME_COLOR_FIELDS: + if field_name not in value: + continue + raw = value[field_name] + if not isinstance(raw, str) or not raw.strip(): + problems.append(f"colors.{field_name} must be a non-empty string") + continue + resolved = _substitute_vars(raw.strip(), variables) + if field_name in _RICH_STYLE_FIELDS: + error = _style_problem(resolved) + elif field_name in _RICH_ONLY_COLOR_FIELDS: + error = _color_problem(resolved) + else: + error = _color_problem(resolved) or _textual_color_problem(resolved) + if error is not None: + problems.append(f"colors.{field_name} {error}") + continue + colors[field_name] = resolved + return colors + + +def _parse_roles( + value: object, + variables: dict[str, str], + problems: list[str], +) -> dict[str, TuiRoleStyle]: + if not isinstance(value, dict): + problems.append("roles must be an object") + return {} + missing = [role for role in TRANSCRIPT_ROLES if role not in value] + if missing: + problems.append("roles missing: " + ", ".join(missing)) + unknown = sorted(set(value) - set(TRANSCRIPT_ROLES)) + if unknown: + problems.append("roles unknown: " + ", ".join(unknown)) + role_styles: dict[str, TuiRoleStyle] = {} + for role in TRANSCRIPT_ROLES: + if role not in value: + continue + raw = value[role] + if not isinstance(raw, dict) or set(raw) != {"border", "body"}: + problems.append(f"roles.{role} must be an object with 'border' and 'body'") + continue + border, body = raw["border"], raw["body"] + if not isinstance(border, str) or not isinstance(body, str): + problems.append(f"roles.{role} border and body must be strings") + continue + resolved_border = _substitute_vars(border.strip(), variables) + resolved_body = _substitute_vars(body.strip(), variables) + # Borders feed Textual's styles.border_left as well as Rich tables. + border_error = _color_problem(resolved_border) or _textual_color_problem(resolved_border) + if border_error is not None: + problems.append(f"roles.{role}.border {border_error}") + # Body colors also feed Textual's styles.color/background in the + # transcript, so both style components must satisfy Textual too. + body_error = _style_problem(resolved_body) or _textual_style_colors_problem(resolved_body) + if body_error is not None: + problems.append(f"roles.{role}.body {body_error}") + if border_error is None and body_error is None: + role_styles[role] = TuiRoleStyle(border=resolved_border, body=resolved_body) + return role_styles + + +def _color_problem(value: str) -> str | None: + try: + Color.parse(value) + except ColorParseError: + return f"is not a valid color: {value!r}" + return None + + +def _textual_color_problem(value: str) -> str | None: + try: + TextualColor.parse(value) + except TextualColorParseError: + return f"is not a color Textual accepts: {value!r}" + return None + + +def _textual_style_colors_problem(value: str) -> str | None: + style = Style.parse(value) + for color in (style.color, style.bgcolor): + if color is not None and color.name is not None: + error = _textual_color_problem(color.name) + if error is not None: + return error + return None + + +def _style_problem(value: str) -> str | None: + try: + Style.parse(value) + except (StyleSyntaxError, ColorParseError): + return f"is not a valid style: {value!r}" + return None + + +def _parse_dark(value: object, colors: dict[str, str], problems: list[str]) -> bool: + if isinstance(value, bool): + return value + if value is not None: + problems.append("dark must be a boolean") + return _is_dark_background(colors.get("screen_background", "")) + + +def _is_dark_background(value: str) -> bool: + tokens = value.split() + if not tokens: + return True + try: + color = Color.parse(tokens[0]) + except ColorParseError: + return True + red, green, blue = color.get_truecolor() + luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 + return luminance < 0.5 + + +def _parse_syntax_theme(value: object, *, dark: bool, problems: list[str]) -> str: + if value is None: + return "ansi_dark" if dark else "ansi_light" + if not isinstance(value, str) or value not in _known_syntax_themes(): + problems.append(f"unknown syntax_theme: {value!r}") + return "ansi_dark" + return value + + +@cache +def _known_syntax_themes() -> frozenset[str]: + from pygments.styles import get_all_styles + + return frozenset(get_all_styles()) | {"ansi_dark", "ansi_light"} + + +def _load_builtin_theme(filename: str) -> TuiTheme: + payload = (files(__package__) / filename).read_text(encoding="utf-8") + return parse_tui_theme_json(loads(payload)) + + +TAU_DARK_THEME = _load_builtin_theme("tau-dark.json") +TAU_LIGHT_THEME = _load_builtin_theme("tau-light.json") +HIGH_CONTRAST_THEME = _load_builtin_theme("high-contrast.json") + +_BUILTIN_THEMES: dict[str, TuiTheme] = { + theme.name: theme for theme in (TAU_DARK_THEME, TAU_LIGHT_THEME, HIGH_CONTRAST_THEME) +} +BUILTIN_TUI_THEME_NAMES: tuple[TuiThemeName, ...] = tuple(_BUILTIN_THEMES) + +_custom_themes: dict[str, TuiTheme] = {} + + +def set_custom_tui_themes(themes: Mapping[str, TuiTheme]) -> None: + """Replace the registered custom themes.""" + _custom_themes.clear() + _custom_themes.update(themes) + + +def available_tui_theme_names() -> tuple[TuiThemeName, ...]: + """Return built-in theme names followed by sorted custom theme names.""" + return (*BUILTIN_TUI_THEME_NAMES, *sorted(_custom_themes)) + + +def get_tui_theme(name: TuiThemeName = "tau-dark") -> TuiTheme: + """Return a built-in or registered custom theme by name.""" + if name in _BUILTIN_THEMES: + return _BUILTIN_THEMES[name] + return _custom_themes[name] + + +def load_custom_tui_themes( + theme_dirs: Sequence[Path], +) -> tuple[dict[str, TuiTheme], list[ResourceDiagnostic]]: + """Load custom themes from directories given in increasing precedence order. + + Invalid files are skipped with a diagnostic; on duplicate names the theme + from the higher-precedence directory (or earlier file within one directory) + wins and the loser is reported as a collision. + """ + themes: dict[str, TuiTheme] = {} + diagnostics: list[ResourceDiagnostic] = [] + for directory in reversed(list(theme_dirs)): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.json")): + try: + data = loads(path.read_text(encoding="utf-8")) + except (OSError, JSONDecodeError, UnicodeDecodeError) as error: + diagnostics.append( + ResourceDiagnostic( + kind="invalid-theme", + message=f"could not parse theme JSON: {error}", + path=path, + ) + ) + continue + try: + theme = parse_tui_theme_json(data) + except TuiThemeError as error: + diagnostics.append( + ResourceDiagnostic(kind="invalid-theme", message=str(error), path=path) + ) + continue + if theme.name in _BUILTIN_THEMES: + diagnostics.append( + ResourceDiagnostic( + kind="collision", + message=f"theme {theme.name!r} shadows a built-in theme and was ignored", + path=path, + name=theme.name, + ) + ) + continue + if theme.name in themes: + diagnostics.append( + ResourceDiagnostic( + kind="collision", + message=f"theme {theme.name!r} is already defined with higher precedence", + path=path, + name=theme.name, + ) + ) + continue + themes[theme.name] = theme + return themes, diagnostics diff --git a/src/tau_coding/tui/themes/high-contrast.json b/src/tau_coding/tui/themes/high-contrast.json new file mode 100644 index 000000000..da7a71759 --- /dev/null +++ b/src/tau_coding/tui/themes/high-contrast.json @@ -0,0 +1,48 @@ +{ + "name": "high-contrast", + "dark": true, + "syntax_theme": "ansi_dark", + "colors": { + "screen_background": "#000000", + "screen_text": "#ffffff", + "chrome_background": "#111111", + "chrome_text": "#ffffff", + "muted_text": "#d0d0d0", + "sidebar_background": "#111111", + "border": "#888888", + "transcript_background": "#000000", + "prompt_background": "#1a1a1a", + "prompt_text": "#ffffff", + "prompt_border": "#00ff66", + "autocomplete_background": "#111111", + "accent": "#ffb454", + "success": "#9cffb1", + "error": "#ff4f4f", + "tool_success_text": "#9cffb1", + "tool_error_text": "#ff4f4f", + "highlight_background": "#7fffd4", + "highlight_text": "#000000", + "markdown_heading": "#ffb454", + "markdown_table_header": "#d0d0d0", + "markdown_table_border": "#d0d0d0", + "markdown_inline_code": "#7fffd4", + "markdown_code_block_background": "#161b21", + "markdown_link": "#80d8ff", + "markdown_bullet": "#ffb454", + "completion_selected": "bold #000000 on #7fffd4", + "completion_selected_description": "#000000 on #7fffd4", + "completion_description": "white" + }, + "roles": { + "user": { "border": "#00b7ff", "body": "white on #1a1a1a" }, + "assistant": { "border": "#00ff66", "body": "white on #001a0b" }, + "tool": { "border": "#ffd000", "body": "white on #211900" }, + "error": { "border": "#ff4f4f", "body": "white on #260000" }, + "status": { "border": "#ffffff", "body": "white on #111111" }, + "thinking": { "border": "#00b7ff", "body": "white on #001626" }, + "skill": { "border": "#ff8cff", "body": "white on #260026" }, + "custom": { "border": "#00ffcc", "body": "white on #001a17" }, + "branch_summary": { "border": "#d8b4fe", "body": "white on #260026" }, + "compaction_summary": { "border": "#d8b4fe", "body": "white on #260026" } + } +} diff --git a/src/tau_coding/tui/themes/tau-dark.json b/src/tau_coding/tui/themes/tau-dark.json new file mode 100644 index 000000000..2fe5144d2 --- /dev/null +++ b/src/tau_coding/tui/themes/tau-dark.json @@ -0,0 +1,48 @@ +{ + "name": "tau-dark", + "dark": true, + "syntax_theme": "ansi_dark", + "colors": { + "screen_background": "#000000", + "screen_text": "#d8dee9", + "chrome_background": "#000000", + "chrome_text": "#d8dee9", + "muted_text": "#667085", + "sidebar_background": "#000000", + "border": "#141922", + "transcript_background": "#000000", + "prompt_background": "#101419", + "prompt_text": "#e5e7eb", + "prompt_border": "#2d3748", + "autocomplete_background": "#000000", + "accent": "#a7f3f0", + "success": "#9cffb1", + "error": "#ff4f4f", + "tool_success_text": "#9cffb1", + "tool_error_text": "#ff4f4f", + "highlight_background": "#a7f3f0", + "highlight_text": "#061a1a", + "markdown_heading": "#a7f3f0", + "markdown_table_header": "#7b7b7b", + "markdown_table_border": "#7b7b7b", + "markdown_inline_code": "#759e95", + "markdown_code_block_background": "#161b21", + "markdown_link": "#93c5fd", + "markdown_bullet": "#a7f3f0", + "completion_selected": "bold #061a1a on #a7f3f0", + "completion_selected_description": "#061a1a on #a7f3f0", + "completion_description": "#667085" + }, + "roles": { + "user": { "border": "#7c8ea6", "body": "#d8dee9 on #101419" }, + "assistant": { "border": "#6ea6a0", "body": "#d8dee9 on #000000" }, + "tool": { "border": "#8a7a52", "body": "#cbd5e1 on #000000" }, + "error": { "border": "#ff4f4f", "body": "#ffb4b4 on #000000" }, + "status": { "border": "#526070", "body": "#aab4c2 on #000000" }, + "thinking": { "border": "#4b5563", "body": "#9ca3af on #000000" }, + "skill": { "border": "#b48ead", "body": "#e5d4ef on #000000" }, + "custom": { "border": "#6ea6a0", "body": "#d8dee9 on #000000" }, + "branch_summary": { "border": "#c084fc", "body": "#e9d5ff on #000000" }, + "compaction_summary": { "border": "#c084fc", "body": "#e9d5ff on #000000" } + } +} diff --git a/src/tau_coding/tui/themes/tau-light.json b/src/tau_coding/tui/themes/tau-light.json new file mode 100644 index 000000000..c31c3bf9b --- /dev/null +++ b/src/tau_coding/tui/themes/tau-light.json @@ -0,0 +1,48 @@ +{ + "name": "tau-light", + "dark": false, + "syntax_theme": "ansi_light", + "colors": { + "screen_background": "#ffffff", + "screen_text": "#111827", + "chrome_background": "#f3f4f6", + "chrome_text": "#111827", + "muted_text": "#475569", + "sidebar_background": "#f8fafc", + "border": "#cbd5e1", + "transcript_background": "#ffffff", + "prompt_background": "#f8fafc", + "prompt_text": "#111827", + "prompt_border": "#2563eb", + "autocomplete_background": "#ffffff", + "accent": "#0f766e", + "success": "#166534", + "error": "#b91c1c", + "tool_success_text": "#166534", + "tool_error_text": "#b91c1c", + "highlight_background": "#dbeafe", + "highlight_text": "#1d4ed8", + "markdown_heading": "#b45309", + "markdown_table_header": "#64748b", + "markdown_table_border": "#cbd5e1", + "markdown_inline_code": "#0f766e", + "markdown_code_block_background": "#f1f5f9", + "markdown_link": "#2563eb", + "markdown_bullet": "#b45309", + "completion_selected": "bold #1d4ed8 on #dbeafe", + "completion_selected_description": "#1d4ed8 on #dbeafe", + "completion_description": "#667085" + }, + "roles": { + "user": { "border": "#2563eb", "body": "#111827 on #f8fafc" }, + "assistant": { "border": "#0f766e", "body": "#111827" }, + "tool": { "border": "#a16207", "body": "#1f2937" }, + "error": { "border": "#b91c1c", "body": "#7f1d1d" }, + "status": { "border": "#64748b", "body": "#334155" }, + "thinking": { "border": "#6b7280", "body": "#4b5563" }, + "skill": { "border": "#7c3aed", "body": "#4c1d95" }, + "custom": { "border": "#0f766e", "body": "#111827" }, + "branch_summary": { "border": "#9333ea", "body": "#581c87" }, + "compaction_summary": { "border": "#9333ea", "body": "#581c87" } + } +} diff --git a/src/tau_coding/tui/widgets.py b/src/tau_coding/tui/widgets.py index e3ca7a0da..014f4612f 100644 --- a/src/tau_coding/tui/widgets.py +++ b/src/tau_coding/tui/widgets.py @@ -1635,46 +1635,26 @@ def render_chat_item( def _chat_item_role_style(item: ChatItem, theme: TuiTheme) -> TuiRoleStyle: if item.role == "tool" and item.tool_result_text: if item.tool_result_text.startswith("✓"): - return TuiRoleStyle( - border=_tool_success_color(theme), - body=theme.role_styles["tool"].body, - ) + return TuiRoleStyle(border=theme.success, body=theme.role_styles["tool"].body) if item.tool_result_text.startswith("✗"): - return TuiRoleStyle(border="#ff4f4f", body=theme.role_styles["tool"].body) + return TuiRoleStyle(border=theme.error, body=theme.role_styles["tool"].body) return theme.role_styles[item.role] def _tool_accent_style(item: ChatItem, *, theme: TuiTheme) -> str | None: + # Bare colors: the accent span inherits its background from the tool body + # style, so it blends with any theme's transcript background. if item.role != "tool": return None if item.tool_result_text is None: return theme.role_styles["tool"].border if item.tool_result_text.startswith("✓"): - return _tool_success_style(theme) + return theme.tool_success_text if item.tool_result_text.startswith("✗"): - return _tool_error_style(theme) + return theme.tool_error_text return None -def _tool_success_color(theme: TuiTheme) -> str: - if theme.name == "tau-light": - return "#166534" - return "#9cffb1" - - -def _tool_success_style(theme: TuiTheme) -> str: - color = _tool_success_color(theme) - if theme.name == "tau-light": - return color - return f"{color} on #000000" - - -def _tool_error_style(theme: TuiTheme) -> str: - if theme.name == "tau-light": - return theme.role_styles["error"].border - return "#ff4f4f on #000000" - - def _render_tool_chat_body( item: ChatItem, *, diff --git a/tests/test_commands.py b/tests/test_commands.py index cd609c304..9baa8b541 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -318,6 +318,31 @@ def test_theme_command_requests_picker_and_sets_theme(tmp_path: Path) -> None: assert "Unknown theme: solarized" in unknown_result.message +def test_theme_command_accepts_registered_custom_theme(tmp_path: Path) -> None: + from tau_coding.tui.themes import ( + THEME_COLOR_FIELDS, + TRANSCRIPT_ROLES, + parse_tui_theme_json, + set_custom_tui_themes, + ) + + theme_data = { + "name": "midnight", + "colors": dict.fromkeys(THEME_COLOR_FIELDS, "#101010"), + "roles": {role: {"border": "#101010", "body": "#e0e0e0"} for role in TRANSCRIPT_ROLES}, + } + set_custom_tui_themes({"midnight": parse_tui_theme_json(theme_data)}) + try: + result = create_default_command_registry().execute(FakeSession(tmp_path), "/theme midnight") + unknown = create_default_command_registry().execute(FakeSession(tmp_path), "/theme nope") + finally: + set_custom_tui_themes({}) + + assert result.theme == "midnight" + assert unknown.message is not None + assert "midnight" in unknown.message + + def test_non_pi_commands_are_not_registered(tmp_path: Path) -> None: registry = create_default_command_registry() session = FakeSession(tmp_path) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 434028bcf..a9ab3f887 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2620,6 +2620,43 @@ def test_tui_app_uses_configured_theme_css_variables() -> None: assert app.current_theme.name == "high-contrast" +def test_tui_app_registers_and_applies_custom_theme() -> None: + from tau_coding.tui.themes import ( + THEME_COLOR_FIELDS, + TRANSCRIPT_ROLES, + parse_tui_theme_json, + set_custom_tui_themes, + ) + + theme_data = { + "name": "midnight", + "colors": dict.fromkeys(THEME_COLOR_FIELDS, "#123456"), + "roles": {role: {"border": "#123456", "body": "#e0e0e0"} for role in TRANSCRIPT_ROLES}, + } + set_custom_tui_themes({"midnight": parse_tui_theme_json(theme_data)}) + try: + app = TauTuiApp(FakeSession(), tui_settings=TuiSettings(theme="midnight")) + + assert app.theme == "midnight" + assert app.get_theme_variable_defaults()["tau-screen-background"] == "#123456" + finally: + set_custom_tui_themes({}) + + +def test_tui_app_falls_back_to_tau_dark_when_theme_is_missing( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + isolate_home(monkeypatch, tmp_path) + + app = TauTuiApp(FakeSession(), tui_settings=TuiSettings(theme="missing-theme")) + + assert app.theme == "tau-dark" + assert any("missing-theme" in item.text for item in app.state.items if item.role == "status") + # The fallback must not be persisted over the user's configured theme: + # if the theme file reappears, their choice should be honored again. + assert not tui_settings_path().exists() + + def test_tui_app_uses_light_theme_css_variables() -> None: app = TauTuiApp(FakeSession(), tui_settings=TuiSettings(theme="tau-light")) @@ -6722,6 +6759,7 @@ async def test_tui_app_runs_initial_prompt() -> None: async def test_run_tui_app_falls_back_to_first_credentialed_provider( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] class FakeCredentialStore: @@ -6905,6 +6943,7 @@ def _boom(provider: object, **kwargs: object) -> object: async def test_run_tui_app_ignores_latest_directory_provider_model_for_new_session( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] latest_record = CodingSessionRecord( id="latest-session", @@ -7008,6 +7047,7 @@ async def run_async(self) -> None: async def test_run_tui_app_does_not_start_new_session_from_scoped_model( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] latest = CodingSessionRecord( id="latest-session", @@ -7118,6 +7158,7 @@ async def run_async(self) -> None: async def test_run_tui_app_creates_new_session_by_default( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] record = CodingSessionRecord( id="new-session", @@ -7213,6 +7254,7 @@ async def run_async(self) -> None: async def test_run_tui_app_opens_when_provider_login_is_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] record = CodingSessionRecord( id="new-session", @@ -7287,6 +7329,7 @@ async def run_async(self) -> None: async def test_run_tui_app_resumes_explicit_session( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] record = CodingSessionRecord( id="session-1", @@ -7382,6 +7425,7 @@ async def run_async(self) -> None: async def test_run_tui_app_ignores_uncredentialed_provider_when_matching_resume_model( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + isolate_home(monkeypatch, tmp_path) calls: list[str] = [] record = CodingSessionRecord( id="session-1", diff --git a/tests/test_tui_config.py b/tests/test_tui_config.py index fbd278b2b..3281f7f7f 100644 --- a/tests/test_tui_config.py +++ b/tests/test_tui_config.py @@ -107,9 +107,18 @@ def test_tui_keybindings_reject_duplicate_keys() -> None: ) -def test_tui_settings_reject_unknown_theme() -> None: - with pytest.raises(TuiConfigError, match="Unknown TUI theme"): - tui_settings_from_json({"theme": "solarized"}) +def test_tui_settings_accept_unknown_theme_and_fall_back_when_resolving() -> None: + settings = tui_settings_from_json({"theme": "solarized"}) + + assert settings.theme == "solarized" + assert settings.resolved_theme == get_tui_theme("tau-dark") + + +def test_tui_settings_reject_non_string_theme() -> None: + with pytest.raises(TuiConfigError, match="theme"): + tui_settings_from_json({"theme": 7}) + with pytest.raises(TuiConfigError, match="theme"): + tui_settings_from_json({"theme": " "}) def test_tui_settings_accept_light_theme() -> None: diff --git a/tests/test_tui_themes.py b/tests/test_tui_themes.py new file mode 100644 index 000000000..9c29c0d42 --- /dev/null +++ b/tests/test_tui_themes.py @@ -0,0 +1,392 @@ +from collections.abc import Iterator +from json import dumps +from pathlib import Path +from typing import Any + +import pytest + +from tau_coding.resources import TauResourcePaths +from tau_coding.tui.config import TuiSettings +from tau_coding.tui.themes import ( + BUILTIN_TUI_THEME_NAMES, + TAU_DARK_THEME, + TAU_LIGHT_THEME, + THEME_COLOR_FIELDS, + TRANSCRIPT_ROLES, + TuiThemeError, + available_tui_theme_names, + get_tui_theme, + load_custom_tui_themes, + parse_tui_theme_json, + set_custom_tui_themes, +) + + +@pytest.fixture(autouse=True) +def _reset_custom_themes() -> Iterator[None]: + yield + set_custom_tui_themes({}) + + +def _theme_data(name: str = "midnight", **overrides: Any) -> dict[str, Any]: + data: dict[str, Any] = { + "name": name, + "colors": {field_name: "#101010" for field_name in THEME_COLOR_FIELDS}, + "roles": {role: {"border": "#101010", "body": "#e0e0e0"} for role in TRANSCRIPT_ROLES}, + } + data.update(overrides) + return data + + +def test_parse_theme_resolves_vars_inside_style_strings() -> None: + data = _theme_data(vars={"base": "#1e1e2e", "teal": "#94e2d5"}) + data["colors"]["screen_background"] = "base" + data["colors"]["completion_selected"] = "bold base on teal" + data["roles"]["user"] = {"border": "teal", "body": "#cdd6f4 on base"} + + theme = parse_tui_theme_json(data) + + assert theme.name == "midnight" + assert theme.screen_background == "#1e1e2e" + assert theme.completion_selected == "bold #1e1e2e on #94e2d5" + assert theme.role_styles["user"].border == "#94e2d5" + assert theme.role_styles["user"].body == "#cdd6f4 on #1e1e2e" + + +def test_parse_theme_defaults_dark_and_syntax_theme_from_background() -> None: + dark_data = _theme_data() + dark_data["colors"]["screen_background"] = "#1e1e2e" + light_data = _theme_data(name="daylight") + light_data["colors"]["screen_background"] = "#eff1f5" + + dark_theme = parse_tui_theme_json(dark_data) + light_theme = parse_tui_theme_json(light_data) + + assert dark_theme.dark is True + assert dark_theme.syntax_theme == "ansi_dark" + assert light_theme.dark is False + assert light_theme.syntax_theme == "ansi_light" + + +def test_parse_theme_honors_explicit_dark_and_syntax_theme() -> None: + data = _theme_data(dark=False, syntax_theme="ansi_light") + data["colors"]["screen_background"] = "#000000" + + theme = parse_tui_theme_json(data) + + assert theme.dark is False + assert theme.syntax_theme == "ansi_light" + + +def test_parse_theme_rejects_unparseable_colors_and_styles() -> None: + data = _theme_data() + data["colors"]["screen_background"] = "not-a-color" + data["colors"]["accent"] = "bold #ffffff on #000000" + data["colors"]["completion_selected"] = "bold nope on #000000" + data["roles"]["user"] = {"border": "#12345", "body": "shiny on #000000"} + + with pytest.raises(TuiThemeError) as exc_info: + parse_tui_theme_json(data) + + message = str(exc_info.value) + assert "colors.screen_background" in message + assert "colors.accent" in message + assert "colors.completion_selected" in message + assert "roles.user.border" in message + assert "roles.user.body" in message + + +@pytest.mark.parametrize("color", ["bright_red", "color(123)", "grey50", "default"]) +def test_parse_theme_rejects_rich_only_colors_in_textual_fields(color: str) -> None: + # Rich's parser accepts these, but Textual's does not; they would crash + # the TUI when the theme's CSS variables are applied. + data = _theme_data() + data["colors"]["accent"] = color + + with pytest.raises(TuiThemeError, match="colors.accent"): + parse_tui_theme_json(data) + + +@pytest.mark.parametrize("color", ["tomato", "ansi_red", "#ff000080"]) +def test_parse_theme_rejects_textual_only_colors_in_shared_fields(color: str) -> None: + # Shared fields also render through Rich, so colors must satisfy both + # parsers; Textual-only syntax is rejected just like Rich-only syntax. + data = _theme_data() + data["colors"]["accent"] = color + + with pytest.raises(TuiThemeError, match="colors.accent"): + parse_tui_theme_json(data) + + +def test_parse_theme_allows_rich_only_colors_in_rich_only_fields() -> None: + data = _theme_data() + data["colors"]["tool_success_text"] = "bright_green" + data["colors"]["tool_error_text"] = "color(160)" + data["colors"]["completion_selected"] = "bold grey50 on #101010" + + theme = parse_tui_theme_json(data) + + assert theme.tool_success_text == "bright_green" + assert theme.completion_selected == "bold grey50 on #101010" + + +@pytest.mark.parametrize("body", ["bright_white on #101010", "#e0e0e0 on grey11", "default"]) +def test_parse_theme_rejects_rich_only_colors_in_role_bodies(body: str) -> None: + # Body foreground/background colors feed Textual's styles.color and + # styles.background, so they must parse under both libraries. + data = _theme_data() + data["roles"]["user"] = {"border": "#101010", "body": body} + + with pytest.raises(TuiThemeError, match="roles.user.body"): + parse_tui_theme_json(data) + + +def test_parse_theme_rejects_rich_only_colors_in_role_borders() -> None: + # Role borders feed Textual's styles.border_left as well as Rich tables. + data = _theme_data() + data["roles"]["user"] = {"border": "bright_red", "body": "#e0e0e0"} + + with pytest.raises(TuiThemeError, match="roles.user.border"): + parse_tui_theme_json(data) + + +def test_parse_theme_rejects_rich_only_var_resolved_into_textual_field() -> None: + data = _theme_data(vars={"base": "grey50"}) + data["colors"]["screen_background"] = "base" + + with pytest.raises(TuiThemeError, match="colors.screen_background"): + parse_tui_theme_json(data) + + +def test_load_custom_themes_skips_textual_invalid_color_with_diagnostic( + tmp_path: Path, +) -> None: + themes_dir = tmp_path / "themes" + crashy = _theme_data(name="crashy") + crashy["colors"]["accent"] = "bright_red" + _write_theme(themes_dir, "crashy.json", crashy) + + themes, diagnostics = load_custom_tui_themes([themes_dir]) + + assert themes == {} + assert len(diagnostics) == 1 + assert diagnostics[0].kind == "invalid-theme" + assert "colors.accent" in diagnostics[0].message + + +def test_parse_theme_reports_all_problems_at_once() -> None: + data = _theme_data() + del data["colors"]["accent"] + del data["colors"]["border"] + del data["roles"]["thinking"] + data["colors"]["not_a_field"] = "#000000" + + with pytest.raises(TuiThemeError) as exc_info: + parse_tui_theme_json(data) + + message = str(exc_info.value) + assert "accent" in message + assert "border" in message + assert "thinking" in message + assert "not_a_field" in message + + +def test_parse_theme_rejects_unknown_top_level_fields() -> None: + with pytest.raises(TuiThemeError, match="palette"): + parse_tui_theme_json(_theme_data(palette={})) + + +def test_parse_theme_allows_schema_field() -> None: + data = _theme_data() + data["$schema"] = "./theme-schema.json" + + assert parse_tui_theme_json(data).name == "midnight" + + +def test_parse_theme_rejects_var_names_that_collide_with_rich_keywords() -> None: + with pytest.raises(TuiThemeError, match="on"): + parse_tui_theme_json(_theme_data(vars={"on": "#101010"})) + + +def test_parse_theme_rejects_var_values_with_whitespace() -> None: + with pytest.raises(TuiThemeError, match="base"): + parse_tui_theme_json(_theme_data(vars={"base": "#101010 on #202020"})) + + +def test_parse_theme_rejects_var_values_that_are_not_colors() -> None: + # A style keyword smuggled through a var would corrupt style strings. + with pytest.raises(TuiThemeError, match="base"): + parse_tui_theme_json(_theme_data(vars={"base": "bold"})) + + +def test_transcript_roles_are_shared_with_tui_state() -> None: + from tau_coding.tui.state import ChatItemRole + from tau_coding.tui.themes import TranscriptRole + + assert ChatItemRole is TranscriptRole + + +def test_parse_theme_rejects_invalid_names() -> None: + with pytest.raises(TuiThemeError, match="name"): + parse_tui_theme_json(_theme_data(name="")) + with pytest.raises(TuiThemeError, match="name"): + parse_tui_theme_json(_theme_data(name="light/dark")) + + +def test_parse_theme_rejects_unknown_syntax_theme() -> None: + with pytest.raises(TuiThemeError, match="syntax_theme"): + parse_tui_theme_json(_theme_data(syntax_theme="not-a-pygments-style")) + + +def test_parse_theme_rejects_non_object_payload() -> None: + with pytest.raises(TuiThemeError, match="object"): + parse_tui_theme_json(["not", "a", "theme"]) + + +def test_builtin_themes_are_loaded_from_packaged_json() -> None: + assert BUILTIN_TUI_THEME_NAMES == ("tau-dark", "tau-light", "high-contrast") + assert TAU_DARK_THEME.dark is True + assert TAU_LIGHT_THEME.dark is False + assert get_tui_theme("tau-dark").screen_background == "#000000" + assert get_tui_theme("high-contrast").prompt_border == "#00ff66" + assert get_tui_theme("tau-light").syntax_theme == "ansi_light" + + +def test_custom_theme_registry_replaces_and_resolves() -> None: + theme = parse_tui_theme_json(_theme_data(name="midnight")) + other = parse_tui_theme_json(_theme_data(name="noon")) + + set_custom_tui_themes({"midnight": theme}) + set_custom_tui_themes({"noon": other}) + + assert available_tui_theme_names() == (*BUILTIN_TUI_THEME_NAMES, "noon") + assert get_tui_theme("noon") is other + with pytest.raises(KeyError): + get_tui_theme("midnight") + + +def test_resolved_theme_falls_back_to_tau_dark_for_unknown_names() -> None: + settings = TuiSettings(theme="missing-theme") + + assert settings.resolved_theme == TAU_DARK_THEME + + +def test_resolved_theme_finds_registered_custom_theme() -> None: + theme = parse_tui_theme_json(_theme_data(name="midnight")) + set_custom_tui_themes({"midnight": theme}) + + assert TuiSettings(theme="midnight").resolved_theme is theme + + +def _write_theme(directory: Path, filename: str, data: dict[str, Any]) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / filename + path.write_text(dumps(data), encoding="utf-8") + return path + + +def test_load_custom_themes_discovers_json_files(tmp_path: Path) -> None: + _write_theme(tmp_path / "themes", "midnight.json", _theme_data(name="midnight")) + + themes, diagnostics = load_custom_tui_themes([tmp_path / "themes"]) + + assert list(themes) == ["midnight"] + assert diagnostics == [] + + +def test_load_custom_themes_prefers_higher_precedence_dirs(tmp_path: Path) -> None: + user_dir = tmp_path / "user" + project_dir = tmp_path / "project" + user_theme = _theme_data(name="midnight") + user_theme["colors"]["accent"] = "#111111" + project_theme = _theme_data(name="midnight") + project_theme["colors"]["accent"] = "#222222" + _write_theme(user_dir, "midnight.json", user_theme) + _write_theme(project_dir, "midnight.json", project_theme) + + themes, diagnostics = load_custom_tui_themes([user_dir, project_dir]) + + assert themes["midnight"].accent == "#222222" + assert any(diagnostic.kind == "collision" for diagnostic in diagnostics) + + +def test_load_custom_themes_skips_invalid_files_with_diagnostics(tmp_path: Path) -> None: + themes_dir = tmp_path / "themes" + themes_dir.mkdir() + (themes_dir / "broken.json").write_text("{not json", encoding="utf-8") + incomplete = _theme_data(name="incomplete") + del incomplete["colors"]["accent"] + _write_theme(themes_dir, "incomplete.json", incomplete) + _write_theme(themes_dir, "midnight.json", _theme_data(name="midnight")) + + themes, diagnostics = load_custom_tui_themes([themes_dir]) + + assert list(themes) == ["midnight"] + assert len(diagnostics) == 2 + + +def test_load_custom_themes_skips_builtin_shadowing_with_diagnostic(tmp_path: Path) -> None: + themes_dir = tmp_path / "themes" + _write_theme(themes_dir, "tau-dark.json", _theme_data(name="tau-dark")) + + themes, diagnostics = load_custom_tui_themes([themes_dir]) + + assert themes == {} + assert any("tau-dark" in diagnostic.message for diagnostic in diagnostics) + + +def test_load_custom_themes_ignores_missing_dirs_and_non_json(tmp_path: Path) -> None: + themes_dir = tmp_path / "themes" + themes_dir.mkdir() + (themes_dir / "notes.md").write_text("not a theme", encoding="utf-8") + + themes, diagnostics = load_custom_tui_themes([themes_dir, tmp_path / "missing"]) + + assert themes == {} + assert diagnostics == [] + + +def test_tool_result_accents_derive_from_theme() -> None: + from rich.console import Console + + from tau_coding.tui.state import ChatItem + from tau_coding.tui.widgets import render_chat_item + + data = _theme_data(vars={"base": "#1e1e2e"}) + data["colors"]["tool_success_text"] = "#00fa9a" + data["colors"]["tool_error_text"] = "#fa0064" + data["roles"]["tool"] = {"border": "#101010", "body": "#e0e0e0 on base"} + theme = parse_tui_theme_json(data) + + console = Console(record=True, width=80) + console.print( + render_chat_item( + ChatItem(role="tool", text="→ read README.md", tool_result_text="✓ read\nok"), + theme=theme, + show_tool_results=True, + ) + ) + console.print( + render_chat_item( + ChatItem(role="tool", text="$ false", tool_result_text="✗ bash\nfailed"), + theme=theme, + show_tool_results=True, + ) + ) + output = console.export_text(styles=True) + + # Success/error accents use the theme tokens, and their background comes + # from the tool body style, never a hardcoded black. + assert "38;2;0;250;154" in output + assert "38;2;250;0;100" in output + assert "48;2;0;0;0" not in output + assert "48;2;30;30;46" in output + + +def test_resource_paths_expose_theme_dirs(tmp_path: Path) -> None: + paths = TauResourcePaths(root=tmp_path / ".tau", cwd=tmp_path / "project") + + assert paths.themes_dirs == ( + tmp_path / ".tau" / "themes", + tmp_path / "project" / ".tau" / "themes", + ) diff --git a/website/content/guides/themes.md b/website/content/guides/themes.md new file mode 100644 index 000000000..0c2a22c39 --- /dev/null +++ b/website/content/guides/themes.md @@ -0,0 +1,83 @@ +--- +title: Themes +description: Restyle the Tau TUI with JSON theme files, from single colors to full custom palettes. +--- + +Tau's TUI themes are JSON files. The built-in themes (`tau-dark`, `tau-light`, +`high-contrast`) ship as JSON inside the package and load through the same +parser as custom themes, so they double as reference examples: +[`src/tau_coding/tui/themes/`](https://github.com/huggingface/tau/tree/main/src/tau_coding/tui/themes). + +## Adding a custom theme + +Drop a `.json` file into one of the theme directories: + +- `~/.tau/themes/` — available in every project +- `/.tau/themes/` — project-local, wins over the user directory on + name collisions + +Themes are discovered at startup; restart Tau after adding or editing a file. +Invalid theme files are skipped with a startup notice — they never prevent Tau +from starting. Select a theme with `/theme `, the `/theme` picker, or +Textual's command palette. The selection is persisted in `~/.tau/tui.json` — +see [Configuration]({{< relref "../reference/configuration.md#tui-settings" >}}). + +## Theme format + +```json +{ + "name": "my-theme", + "dark": true, + "syntax_theme": "ansi_dark", + "vars": { + "base": "#1e1e2e", + "text": "#cdd6f4", + "teal": "#94e2d5" + }, + "colors": { + "screen_background": "base", + "screen_text": "text", + "completion_selected": "bold base on teal", + "…": "all color fields are required" + }, + "roles": { + "user": { "border": "teal", "body": "text on base" }, + "…": "all roles are required" + } +} +``` + +- `name` (required) — unique; must not contain `/` and cannot shadow a + built-in theme name. +- `dark` (optional) — whether the theme is dark. Defaults from the luminance + of `screen_background`. Controls dark-vs-light rendering details such as + tool-result colors. +- `syntax_theme` (optional) — Pygments style for fenced code blocks, or + `ansi_dark` / `ansi_light`. Defaults from `dark`. +- `vars` (optional) — named colors. Any whitespace-separated token in a + `colors` or `roles` value that matches a var name is replaced by its value, + so compound Rich styles like `"bold base on teal"` work. Var names that + collide with Rich style keywords (`on`, `bold`, `dim`, …) are rejected, and + var values must be single color tokens. +- `colors` (required) — every field of the theme palette, including the + `success` / `error` status tokens and the `tool_success_text` / + `tool_error_text` colors for tool invocation text. The full list is + `THEME_COLOR_FIELDS` in `src/tau_coding/tui/themes/__init__.py`; the + built-in theme JSON files show them all in context. + Most colors are rendered by both Rich and Textual, so stick to formats both + accept — six-digit hex like `#94e2d5` is always safe. Library-specific + syntax such as Rich's `bright_red` / `grey50` or Textual's `ansi_red` / + `#ff000080` is rejected, except in the Rich-only `tool_success_text` / + `tool_error_text` fields and the `completion_*` style strings. +- `roles` (required) — `border` and `body` styles for each transcript role: + `user`, `assistant`, `tool`, `error`, `status`, `thinking`, `skill`, + `custom` (extension messages), `branch_summary`, `compaction_summary`. + `body` is a Rich style string, so + it can carry a background (`"#cdd6f4 on #1e1e2e"`); its colors also tint + the surrounding Textual widget, so like the palette colors they must use + formats both libraries accept. The `border` is the + colored bar beside each transcript block; completed tool calls override the + `tool` border with `success` or `error`. + +Validation reports every problem in a file at once, so a new theme can be +fixed in one pass. diff --git a/website/content/guides/tui.md b/website/content/guides/tui.md index 96efcb471..7eaa67153 100644 --- a/website/content/guides/tui.md +++ b/website/content/guides/tui.md @@ -108,10 +108,12 @@ when you want to reduce what is sent to the model. - **Ctrl+P** quickly cycles through your *scoped* (favorite) models without opening the picker. Manage that list with `/scoped-models` or by pressing `Space` on a model in the `/model` picker. -- **`/theme`** switches between `tau-dark`, `tau-light`, and `high-contrast`. - Each theme uses one shared selection palette for prompt autocomplete and modal - lists such as `/resume`. In `tau-dark`, the aqua selection color is also the - global accent used for headings, prompt activity, and other emphasized UI. +- **`/theme`** switches between `tau-dark`, `tau-light`, `high-contrast`, and + any custom themes you have installed. Each theme uses one shared selection + palette for prompt autocomplete and modal lists such as `/resume`. In + `tau-dark`, the aqua selection color is also the global accent used for + headings, prompt activity, and other emphasized UI. See + [Themes]({{< relref "./themes.md" >}}). ## The sidebar diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index aaf0f7391..c56164289 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -19,6 +19,7 @@ those locations and file formats. ├── sessions/ # saved sessions, per project ├── skills/ # user-level skills ├── prompts/ # user-level prompt templates +├── themes/ # user-level TUI themes ├── AGENTS.md # global project instructions └── logs/ # diagnostics ``` @@ -240,11 +241,14 @@ The built-in frontend reads optional settings from `~/.tau/tui.json`: } ``` -Built-in themes: `tau-dark` (default), `tau-light`, `high-contrast`. Set one with -`/theme`. Textual's native theme picker is mapped to the same Tau themes and -persists the same `theme` setting. Keys use Textual syntax; omitted keys keep -their defaults. Tau rejects unknown themes/keybinding names, empty keys, and -duplicate assignments. +Built-in themes: `tau-dark` (default), `tau-light`, `high-contrast`. Custom +themes are JSON files in `~/.tau/themes/` or a project's `.tau/themes/` — see +[Themes]({{< relref "../guides/themes.md" >}}). Set one with `/theme`. +Textual's native theme picker is mapped to the same Tau themes and persists +the same `theme` setting. A configured theme that cannot be found falls back +to `tau-dark` with a startup notice, without overwriting the setting. Keys use +Textual syntax; omitted keys keep their defaults. Tau rejects unknown +keybinding names, empty keys, and duplicate assignments. - `sidebar_position`: `"left"` (default), `"right"`, or `"off"`. Controls placement of the session metadata sidebar. `"off"` hides the sidebar entirely; diff --git a/website/data/sidebar.yaml b/website/data/sidebar.yaml index 76969d3c1..4b586c137 100644 --- a/website/data/sidebar.yaml +++ b/website/data/sidebar.yaml @@ -26,6 +26,8 @@ path: /guides/print-mode/ - label: Extensions path: /guides/extensions/ + - label: Themes + path: /guides/themes/ - label: Reference items: - label: CLI