Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions src/tau_coding/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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}",
Expand Down
9 changes: 9 additions & 0 deletions src/tau_coding/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions src/tau_coding/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
70 changes: 54 additions & 16 deletions src/tau_coding/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -129,7 +130,6 @@
build_completion_state,
)
from tau_coding.tui.config import (
BUILTIN_TUI_THEME_NAMES,
TAU_DARK_THEME,
TuiKeybindings,
TuiSettings,
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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."""
Expand All @@ -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",
)
Expand All @@ -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()
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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),
)

Expand Down Expand Up @@ -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(),
Expand Down
Loading