mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
9d463f52fb
Improved responsiveness during streaming and rendering (faster tool-call streaming, cached theme/charset lookups). --- - `execute_task_textual`: streamed `tool_call_chunk` fragments were re-joined into one string and `json.loads`-ed on *every* fragment, which is O(n²) on the UI event loop and worst for large `edit_file` argument blobs. Now fragments accumulate in a list and the buffer is joined + parsed only when it looks like complete JSON (or on the final chunk). - `get_theme_colors`: derived built-in `ThemeColors` are now memoized per `(theme name, dark)` instead of re-running ~16 hex validations on every widget render; the cache is cleared on `reload_registry`. - `_detect_charset_mode`: cached for the process like `get_glyphs` already is, since charset mode can't change at runtime. Reset alongside the glyph cache. - `AssistantMessage`: streamed text accumulates in a list and the full string is materialized lazily, avoiding per-token concatenation. - `_compose_diff_content`: the hunk-header regex is compiled once at module level, and the stats/max-line scans are folded into a single pass over the diff lines. Made by [Open SWE](https://openswe.vercel.app) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
892 lines
29 KiB
Python
892 lines
29 KiB
Python
"""LangChain brand colors and semantic constants for the app.
|
|
|
|
Single source of truth for color values used in Python code (Rich markup,
|
|
`Content.styled`, `Content.from_markup`). CSS-side styling should reference
|
|
Textual CSS variables: built-in variables
|
|
(`$primary`, `$background`, `$text-muted`, `$error-muted`, etc.) are set via
|
|
`register_theme()` in `DeepAgentsApp.__init__`, while the few app-specific
|
|
variables (`$mode-bash`, `$mode-command`, `$mode-incognito`, `$skill`,
|
|
`$skill-hover`, `$tool`, `$tool-hover`) are backed by these constants via
|
|
`App.get_theme_variable_defaults()`.
|
|
|
|
Code that needs custom CSS variable values should call
|
|
`get_css_variable_defaults(dark=...)`. For the full semantic color palette, look
|
|
up the `ThemeColors` instance via `get_registry()`.
|
|
|
|
Users can define custom themes in `~/.deepagents/config.toml` under
|
|
`[themes.<name>]` sections. Each new theme section must include `label` (str);
|
|
`dark` (bool) defaults to `False` if omitted (set to `True` for dark themes).
|
|
Color fields are optional and fall back to the built-in dark/light palette based
|
|
on the `dark` flag. Sections whose name matches a built-in theme override its
|
|
colors without replacing it. See `_load_user_themes()` for details.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, fields
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
|
|
from textual.app import App
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Brand palette — dark (originally tokyonight-inspired, LangChain blue primary)
|
|
# ---------------------------------------------------------------------------
|
|
LC_DARK = "#11121D"
|
|
"""Background — visible blue tint, distinguishable from pure black."""
|
|
|
|
LC_CARD = "#1A1B2E"
|
|
"""Surface / card — clearly elevated above background."""
|
|
|
|
LC_BORDER_DK = "#25283B"
|
|
"""Borders on dark backgrounds."""
|
|
|
|
LC_BORDER_LT = "#3A3E57"
|
|
"""Borders on lighter / hovered backgrounds."""
|
|
|
|
LC_BODY = "#C0CAF5"
|
|
"""Body text — high contrast on dark backgrounds."""
|
|
|
|
LC_BLUE = "#7AA2F7"
|
|
"""Primary accent blue."""
|
|
|
|
LC_PURPLE = "#BB9AF7"
|
|
"""Secondary accent / badges / labels."""
|
|
|
|
LC_GREEN = "#9ECE6A"
|
|
"""Success / positive indicator."""
|
|
|
|
LC_AMBER = "#EB8B46"
|
|
"""Warning / caution indicator."""
|
|
|
|
LC_PINK = "#F7768E"
|
|
"""Error / destructive actions."""
|
|
|
|
LC_MUTED = "#545C7E"
|
|
"""Muted / secondary text."""
|
|
|
|
LC_GREEN_BG = "#1C2A38"
|
|
"""Subtle green-tinted background for diff additions."""
|
|
|
|
LC_PINK_BG = "#2A1F32"
|
|
"""Subtle pink-tinted background for diff removals / errors."""
|
|
|
|
LC_PANEL = "#25283B"
|
|
"""Panel — differentiated section background (above surface)."""
|
|
|
|
LC_SKILL = "#A78BFA"
|
|
"""Skill invocation accent — border and header text."""
|
|
|
|
LC_SKILL_HOVER = "#C4B5FD"
|
|
"""Skill invocation hover — lighter variant for interactive feedback."""
|
|
|
|
LC_TOOL = LC_AMBER
|
|
"""Tool call accent — border and header text."""
|
|
|
|
LC_TOOL_HOVER = "#FFCB91"
|
|
"""Tool call hover — lighter variant for interactive feedback."""
|
|
|
|
LC_INCOGNITO = "#2DD4BF"
|
|
"""Incognito shell accent — teal, must read distinctly against `LC_PINK`
|
|
shell and `error` warning border colors."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Brand palette — light
|
|
# ---------------------------------------------------------------------------
|
|
LC_LIGHT_BG = "#F5F5F7"
|
|
"""Background — warm neutral white."""
|
|
|
|
LC_LIGHT_SURFACE = "#EAEAEE"
|
|
"""Surface / card — slightly darker than background."""
|
|
|
|
LC_LIGHT_BORDER = "#C8CAD0"
|
|
"""Borders on light backgrounds."""
|
|
|
|
LC_LIGHT_BORDER_HVR = "#A0A4B0"
|
|
"""Borders on hovered / focused surfaces."""
|
|
|
|
LC_LIGHT_BODY = "#24283B"
|
|
"""Body text — high contrast on light backgrounds."""
|
|
|
|
LC_LIGHT_BLUE = "#2E5EAA"
|
|
"""Primary accent blue (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_PURPLE = "#7C3AED"
|
|
"""Secondary accent (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_GREEN = "#3A7D0A"
|
|
"""Success / positive (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_AMBER = "#B45309"
|
|
"""Warning / caution (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_PINK = "#BE185D"
|
|
"""Error / destructive (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_MUTED = "#6B7280"
|
|
"""Muted / secondary text on light backgrounds."""
|
|
|
|
LC_LIGHT_GREEN_BG = "#DCFCE7"
|
|
"""Subtle green-tinted background for diff additions."""
|
|
|
|
LC_LIGHT_PINK_BG = "#FEE2E2"
|
|
"""Subtle pink-tinted background for diff removals / errors."""
|
|
|
|
LC_LIGHT_PANEL = "#E0E1E6"
|
|
"""Panel for light theme — differentiated section background."""
|
|
|
|
LC_LIGHT_SKILL = "#7C3AED"
|
|
"""Skill invocation accent (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_SKILL_HOVER = "#6D28D9"
|
|
"""Skill invocation hover (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_TOOL = LC_LIGHT_AMBER
|
|
"""Tool call accent (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_TOOL_HOVER = "#78350F"
|
|
"""Tool call hover (darkened for light bg contrast)."""
|
|
|
|
LC_LIGHT_INCOGNITO = "#0F766E"
|
|
"""Incognito shell accent (darkened for light bg contrast)."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Semantic constants (ANSI color names for Rich console output)
|
|
#
|
|
# These are ANSI color names resolved by the user's terminal palette, so they
|
|
# adapt to both dark and light terminal backgrounds automatically. They are
|
|
# used in Rich's `Console.print()` (non-interactive output, help screens,
|
|
# `non_interactive.py`, `main.py`).
|
|
#
|
|
# Textual widget code should NOT use these. Instead, call
|
|
# `get_theme_colors(self.app)` to obtain the active theme's `ThemeColors`
|
|
# (hex values), or reference CSS variables (`$primary`, `$muted`, etc.).
|
|
# ---------------------------------------------------------------------------
|
|
PRIMARY = "blue"
|
|
"""Default accent for headings, borders, links, and active elements."""
|
|
|
|
PRIMARY_DEV = "bright_red"
|
|
"""Accent used when running from an editable (dev) install."""
|
|
|
|
SUCCESS = "green"
|
|
"""Positive outcomes — tool success, approved actions."""
|
|
|
|
WARNING = "yellow"
|
|
"""Caution and notice states — auto-approve off, pending tool calls, notices."""
|
|
|
|
MUTED = "bright_black"
|
|
"""De-emphasized text — timestamps, secondary labels."""
|
|
|
|
MODE_BASH = "red"
|
|
"""Shell mode indicator — borders, prompts, and message prefixes."""
|
|
|
|
MODE_COMMAND = "magenta"
|
|
"""Command mode indicator — borders, prompts, and message prefixes."""
|
|
|
|
# Diff colors
|
|
DIFF_ADD_FG = "green"
|
|
"""Added-line foreground in inline diffs."""
|
|
|
|
DIFF_ADD_BG = "green"
|
|
"""Added-line background in inline diffs."""
|
|
|
|
DIFF_REMOVE_FG = "red"
|
|
"""Removed-line foreground in inline diffs."""
|
|
|
|
DIFF_REMOVE_BG = "red"
|
|
"""Removed-line background in inline diffs."""
|
|
|
|
DIFF_CONTEXT = "bright_black"
|
|
"""Unchanged context lines in inline diffs."""
|
|
|
|
# Tool call widget
|
|
TOOL_BORDER = "bright_black"
|
|
"""Tool call card border."""
|
|
|
|
TOOL_HEADER = "yellow"
|
|
"""Tool call headers, slash-command tokens, and approval-menu commands."""
|
|
|
|
# File listing colors
|
|
FILE_PYTHON = "blue"
|
|
"""Python files in tool-call file listings."""
|
|
|
|
FILE_CONFIG = "yellow"
|
|
"""Config / data files in tool-call file listings."""
|
|
|
|
FILE_DIR = "green"
|
|
"""Directories in tool-call file listings."""
|
|
|
|
SPINNER = "blue"
|
|
"""Loading spinner color."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Theme variant dataclass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_textual_colors_cache: dict[tuple[str, bool], ThemeColors] = {}
|
|
"""Cache of derived built-in `ThemeColors` keyed on `(theme name, dark)`.
|
|
|
|
A built-in Textual theme does not change its color values once registered under
|
|
a name, so its derived colors only change when the active theme changes. Caching
|
|
avoids re-running over a dozen hex validations on every widget render. The `dark`
|
|
flag is part of the key defensively; for built-in themes it is already fixed by
|
|
the name. Only registered built-ins are cached (see `get_theme_colors`) — the
|
|
cache is cleared by `reload_registry`.
|
|
"""
|
|
|
|
|
|
_HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
"""Matches a 7-character hex color string like `#7AA2F7`.
|
|
|
|
Textual's `Color.parse` could also validate, but importing it here would pull
|
|
Textual into `theme.py` which is otherwise pure Python with zero framework deps.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ThemeColors:
|
|
"""Complete set of semantic colors for one theme variant.
|
|
|
|
Every field must be a 7-character hex color string (e.g., `'#7AA2F7'`).
|
|
"""
|
|
|
|
primary: str
|
|
"""Accent for headings, borders, links, and active elements."""
|
|
|
|
secondary: str
|
|
"""Secondary accent for badges, labels, and decorative highlights."""
|
|
|
|
accent: str
|
|
"""Attention-drawing contrast accent, distinct from primary/secondary."""
|
|
|
|
panel: str
|
|
"""Differentiated section background (above surface)."""
|
|
|
|
success: str
|
|
"""Positive outcomes — tool success, approved actions."""
|
|
|
|
warning: str
|
|
"""Caution and notice states — pending tool calls, notices."""
|
|
|
|
error: str
|
|
"""Error and destructive-action indicator."""
|
|
|
|
muted: str
|
|
"""De-emphasized text — timestamps, secondary labels."""
|
|
|
|
mode_bash: str
|
|
"""Shell mode indicator — borders, prompts, and message prefixes."""
|
|
|
|
mode_command: str
|
|
"""Command mode indicator — borders, prompts, and message prefixes."""
|
|
|
|
mode_incognito: str
|
|
"""Incognito shell indicator — borders, prompts, and message prefixes."""
|
|
|
|
skill: str
|
|
"""Skill invocation accent — border and header text."""
|
|
|
|
skill_hover: str
|
|
"""Skill invocation hover — contrasting variant for interactive feedback."""
|
|
|
|
tool: str
|
|
"""Tool call accent — border and header text."""
|
|
|
|
tool_hover: str
|
|
"""Tool call hover — contrasting variant for interactive feedback."""
|
|
|
|
foreground: str
|
|
"""Primary body text."""
|
|
|
|
background: str
|
|
"""Base application background."""
|
|
|
|
surface: str
|
|
"""Elevated card / panel background."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Validate that every field is a valid hex color.
|
|
|
|
Raises:
|
|
ValueError: If any field is not a 7-character hex color string.
|
|
"""
|
|
for f in fields(self):
|
|
val = getattr(self, f.name)
|
|
if not _HEX_RE.match(val):
|
|
msg = (
|
|
f"ThemeColors.{f.name} must be a 7-char hex color"
|
|
f" (#RRGGBB), got {val!r}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
@classmethod
|
|
def merged(cls, base: ThemeColors, overrides: dict[str, str]) -> ThemeColors:
|
|
"""Create a new `ThemeColors` by overlaying overrides onto a base.
|
|
|
|
Fields present in `overrides` replace the corresponding base value;
|
|
missing fields inherit from `base`. This lets users specify only the
|
|
colors they want to customize.
|
|
|
|
Args:
|
|
base: Fallback color set for any field not in `overrides`.
|
|
overrides: Field-name to hex-color mapping. Unknown keys are
|
|
silently ignored.
|
|
|
|
Returns:
|
|
New `ThemeColors` with merged values.
|
|
"""
|
|
valid_names = {f.name for f in fields(cls)}
|
|
kwargs = {f.name: getattr(base, f.name) for f in fields(cls)}
|
|
kwargs.update({k: v for k, v in overrides.items() if k in valid_names})
|
|
return cls(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in theme color sets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DARK_COLORS = ThemeColors(
|
|
primary=LC_BLUE,
|
|
secondary=LC_PURPLE,
|
|
accent=LC_GREEN,
|
|
panel=LC_PANEL,
|
|
success=LC_GREEN,
|
|
warning=LC_AMBER,
|
|
error=LC_PINK,
|
|
muted=LC_MUTED,
|
|
mode_bash=LC_PINK,
|
|
mode_command=LC_PURPLE,
|
|
mode_incognito=LC_INCOGNITO,
|
|
skill=LC_SKILL,
|
|
skill_hover=LC_SKILL_HOVER,
|
|
tool=LC_TOOL,
|
|
tool_hover=LC_TOOL_HOVER,
|
|
foreground=LC_BODY,
|
|
background=LC_DARK,
|
|
surface=LC_CARD,
|
|
)
|
|
"""Color set for the dark LangChain theme."""
|
|
|
|
LIGHT_COLORS = ThemeColors(
|
|
primary=LC_LIGHT_BLUE,
|
|
secondary=LC_LIGHT_PURPLE,
|
|
accent=LC_LIGHT_GREEN,
|
|
panel=LC_LIGHT_PANEL,
|
|
success=LC_LIGHT_GREEN,
|
|
warning=LC_LIGHT_AMBER,
|
|
error=LC_LIGHT_PINK,
|
|
muted=LC_LIGHT_MUTED,
|
|
mode_bash=LC_LIGHT_PINK,
|
|
mode_command=LC_LIGHT_PURPLE,
|
|
mode_incognito=LC_LIGHT_INCOGNITO,
|
|
skill=LC_LIGHT_SKILL,
|
|
skill_hover=LC_LIGHT_SKILL_HOVER,
|
|
tool=LC_LIGHT_TOOL,
|
|
tool_hover=LC_LIGHT_TOOL_HOVER,
|
|
foreground=LC_LIGHT_BODY,
|
|
background=LC_LIGHT_BG,
|
|
surface=LC_LIGHT_SURFACE,
|
|
)
|
|
"""Color set for the light LangChain theme."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Available themes (name → display label, dark flag, colors)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ThemeEntry:
|
|
"""Metadata for a registered theme."""
|
|
|
|
label: str
|
|
"""Human-readable label shown in the theme picker."""
|
|
|
|
dark: bool
|
|
"""Whether this is a dark theme variant."""
|
|
|
|
colors: ThemeColors
|
|
"""Resolved color set."""
|
|
|
|
custom: bool = True
|
|
"""Whether this theme must be registered with Textual via `register_theme()`.
|
|
|
|
`True` for LangChain-branded themes and user-defined themes.
|
|
`False` for Textual built-in themes that Textual already knows about.
|
|
"""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Validate that the label is a non-empty string.
|
|
|
|
Raises:
|
|
ValueError: If `label` is empty or whitespace-only.
|
|
"""
|
|
if not self.label.strip():
|
|
msg = "ThemeEntry.label must be a non-empty string"
|
|
raise ValueError(msg)
|
|
|
|
|
|
# Curated labels for Textual built-in themes. Themes not listed here fall back
|
|
# to a humanized version of the slug (e.g. `ansi-dark` → `Ansi Dark`), so newly
|
|
# shipped Textual themes appear in the picker without code changes.
|
|
_TEXTUAL_THEME_LABELS: Mapping[str, str] = MappingProxyType(
|
|
{
|
|
"textual-dark": "Textual Dark",
|
|
"textual-light": "Textual Light",
|
|
"ansi-dark": "Terminal ANSI Dark",
|
|
"ansi-light": "Terminal ANSI Light",
|
|
"catppuccin-frappe": "Catppuccin Frappé",
|
|
"rose-pine": "Rosé Pine",
|
|
"rose-pine-dawn": "Rosé Pine Dawn",
|
|
"rose-pine-moon": "Rosé Pine Moon",
|
|
"tokyo-night": "Tokyo Night",
|
|
}
|
|
)
|
|
|
|
|
|
def _builtin_themes() -> dict[str, ThemeEntry]:
|
|
"""Return the built-in theme entries as a mutable dict.
|
|
|
|
Textual built-ins are discovered from `textual.theme.BUILTIN_THEMES` so
|
|
newly shipped Textual themes appear automatically. They are not registered
|
|
via `register_theme()` — Textual's own `$primary`, `$background`, etc.
|
|
apply. The `colors` field provides fallback values for app-specific CSS
|
|
vars (`$mode-bash`, `$mode-command`, `$mode-incognito`) and Python-side
|
|
styling. For standard properties (primary, secondary, etc.),
|
|
`get_theme_colors()` dynamically
|
|
resolves from the actual Textual theme at runtime so the Python and CSS
|
|
color systems stay in sync.
|
|
|
|
Returns:
|
|
Dict of built-in theme names to `ThemeEntry` instances.
|
|
"""
|
|
r: dict[str, ThemeEntry] = {}
|
|
r["langchain"] = ThemeEntry(
|
|
label="LangChain Dark",
|
|
dark=True,
|
|
colors=DARK_COLORS,
|
|
)
|
|
r["langchain-light"] = ThemeEntry(
|
|
label="LangChain Light",
|
|
dark=False,
|
|
colors=LIGHT_COLORS,
|
|
)
|
|
|
|
from textual.theme import BUILTIN_THEMES
|
|
|
|
for name, builtin in BUILTIN_THEMES.items():
|
|
label = _TEXTUAL_THEME_LABELS.get(name) or name.replace("-", " ").title()
|
|
r[name] = ThemeEntry(
|
|
label=label,
|
|
dark=builtin.dark,
|
|
colors=DARK_COLORS if builtin.dark else LIGHT_COLORS,
|
|
custom=False,
|
|
)
|
|
return r
|
|
|
|
|
|
@functools.cache
|
|
def _builtin_names() -> frozenset[str]:
|
|
"""Names of built-in themes; lazily computed and cached.
|
|
|
|
User `[themes.<name>]` sections matching a built-in name override its colors
|
|
rather than creating a new theme. Derived from `_builtin_themes()` so the
|
|
set stays in sync automatically. Lazy because `_builtin_themes()` imports
|
|
`textual.theme.BUILTIN_THEMES`, and we don't want to pull Textual onto the
|
|
`deepagents --help` / `deepagents -v` cold-start path.
|
|
|
|
Returns:
|
|
Frozen set of built-in theme names.
|
|
"""
|
|
return frozenset(_builtin_themes())
|
|
|
|
|
|
def _load_user_themes(
|
|
builtins: dict[str, ThemeEntry],
|
|
*,
|
|
config_path: Path | None = None,
|
|
) -> None:
|
|
"""Load user-defined themes from `config.toml` into `builtins` (mutated).
|
|
|
|
**New themes** — each `[themes.<name>]` section (where `<name>` is not a
|
|
built-in) must have:
|
|
|
|
- `label` (str) — human-readable name shown in the theme picker.
|
|
- `dark` (bool, optional) — whether this is a dark-mode variant.
|
|
|
|
Defaults to `False` (light).
|
|
|
|
**Built-in overrides** — if `<name>` matches a built-in theme, only color
|
|
fields are read; `label` and `dark` are inherited from the built-in.
|
|
|
|
All `ThemeColors` fields are optional. For new themes, omitted fields
|
|
fall back to the built-in dark or light palette based on the `dark` flag.
|
|
|
|
For built-in overrides, omitted fields retain the existing built-in colors.
|
|
|
|
Invalid themes (bad hex, missing required keys) are logged as warnings
|
|
and skipped — they never crash startup.
|
|
|
|
Example `config.toml` snippet:
|
|
|
|
```toml
|
|
# New custom theme
|
|
[themes.my-solarized]
|
|
label = "My Solarized"
|
|
dark = true
|
|
primary = "#268BD2"
|
|
warning = "#B58900"
|
|
|
|
# Override built-in theme colors
|
|
[themes.langchain]
|
|
primary = "#FF5500"
|
|
```
|
|
|
|
Args:
|
|
builtins: Mutable dict to update (new themes are added, built-in
|
|
overrides replace existing entries).
|
|
config_path: Override for the config file path (testing).
|
|
"""
|
|
if config_path is None:
|
|
try:
|
|
config_path = Path.home() / ".deepagents" / "config.toml"
|
|
except RuntimeError:
|
|
logger.debug("Cannot determine home directory; skipping user theme loading")
|
|
return
|
|
|
|
import tomllib
|
|
|
|
try:
|
|
if not config_path.exists():
|
|
return
|
|
|
|
with config_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
|
logger.warning(
|
|
"Could not read %s for user themes: %s",
|
|
config_path,
|
|
exc,
|
|
)
|
|
return
|
|
|
|
themes_section: Any = data.get("themes")
|
|
if not isinstance(themes_section, dict) or not themes_section:
|
|
return
|
|
|
|
valid_color_names = {f.name for f in fields(ThemeColors)}
|
|
reserved = {"label", "dark"}
|
|
|
|
for name, section in themes_section.items():
|
|
if not isinstance(section, dict):
|
|
logger.warning("Ignoring non-table [themes.%s]", name)
|
|
continue
|
|
|
|
# --- Parse color overrides (shared by built-in overrides & new themes)
|
|
color_overrides: dict[str, str] = {}
|
|
for k, v in section.items():
|
|
if k in reserved:
|
|
continue
|
|
if not isinstance(v, str):
|
|
logger.warning(
|
|
"User theme '%s' field '%s' must be a string, got %s; ignoring",
|
|
name,
|
|
k,
|
|
type(v).__name__,
|
|
)
|
|
continue
|
|
if k in valid_color_names:
|
|
color_overrides[k] = v
|
|
else:
|
|
logger.warning(
|
|
"User theme '%s' has unknown color field '%s'; ignoring",
|
|
name,
|
|
k,
|
|
)
|
|
|
|
# --- Built-in override: merge color tweaks into the existing entry
|
|
if name in _builtin_names():
|
|
existing = builtins.get(name)
|
|
if existing is None:
|
|
logger.warning(
|
|
"Built-in theme '%s' not in builtins dict; skipping override",
|
|
name,
|
|
)
|
|
continue
|
|
if not color_overrides:
|
|
continue
|
|
try:
|
|
colors = ThemeColors.merged(existing.colors, color_overrides)
|
|
except ValueError as exc:
|
|
logger.warning(
|
|
"Built-in theme '%s' color override invalid: %s; skipping",
|
|
name,
|
|
exc,
|
|
)
|
|
continue
|
|
builtins[name] = ThemeEntry(
|
|
label=existing.label,
|
|
dark=existing.dark,
|
|
colors=colors,
|
|
custom=existing.custom,
|
|
)
|
|
continue
|
|
|
|
# --- New custom theme: label required, dark defaults to False (light)
|
|
label = section.get("label")
|
|
if not isinstance(label, str) or not label.strip():
|
|
logger.warning(
|
|
"User theme '%s' missing required 'label' (str); skipping",
|
|
name,
|
|
)
|
|
continue
|
|
|
|
dark = section.get("dark", False)
|
|
if not isinstance(dark, bool):
|
|
logger.warning(
|
|
"User theme '%s': 'dark' must be true or false, got %s (%r);"
|
|
" defaulting to light",
|
|
name,
|
|
type(dark).__name__,
|
|
dark,
|
|
)
|
|
dark = False
|
|
|
|
base = DARK_COLORS if dark else LIGHT_COLORS
|
|
try:
|
|
colors = ThemeColors.merged(base, color_overrides)
|
|
except ValueError as exc:
|
|
logger.warning(
|
|
"User theme '%s' has invalid colors: %s; skipping",
|
|
name,
|
|
exc,
|
|
)
|
|
continue
|
|
|
|
builtins[name] = ThemeEntry(
|
|
label=label,
|
|
dark=dark,
|
|
colors=colors,
|
|
custom=True,
|
|
)
|
|
|
|
|
|
def _build_registry(
|
|
*, config_path: Path | None = None
|
|
) -> MappingProxyType[str, ThemeEntry]:
|
|
"""Build and freeze the theme registry (built-in + user themes).
|
|
|
|
Args:
|
|
config_path: Override for the config file path (testing).
|
|
|
|
Returns:
|
|
Read-only mapping of theme names to `ThemeEntry` instances.
|
|
"""
|
|
r = _builtin_themes()
|
|
_load_user_themes(r, config_path=config_path)
|
|
return MappingProxyType(r)
|
|
|
|
|
|
@functools.cache
|
|
def get_registry() -> MappingProxyType[str, ThemeEntry]:
|
|
"""Return the read-only theme registry, building it on first access.
|
|
|
|
Lazy so that `theme.py` can be imported on the `deepagents --help` cold
|
|
path without pulling in `textual.theme.BUILTIN_THEMES` (which transitively
|
|
imports Textual, ~470ms). Inside a Textual app, the build is microseconds
|
|
because Textual is already loaded; callers like `_register_custom_themes()`
|
|
iterate the result during `App.__init__`, which warms the cache before any
|
|
user-facing surface (e.g. the theme picker) reads it.
|
|
"""
|
|
return _build_registry()
|
|
|
|
|
|
DEFAULT_THEME = "langchain"
|
|
"""Theme name used when no preference is saved."""
|
|
|
|
|
|
def reload_registry() -> MappingProxyType[str, ThemeEntry]:
|
|
"""Rebuild the theme registry from disk.
|
|
|
|
Re-reads `~/.deepagents/config.toml` for user-defined themes so that
|
|
`/reload` can pick up config changes without restarting the app.
|
|
|
|
Returns:
|
|
The new frozen registry.
|
|
"""
|
|
get_registry.cache_clear()
|
|
_builtin_names.cache_clear()
|
|
_textual_colors_cache.clear()
|
|
return get_registry()
|
|
|
|
|
|
def get_css_variable_defaults(
|
|
*, dark: bool = True, colors: ThemeColors | None = None
|
|
) -> dict[str, str]:
|
|
"""Return custom CSS variable defaults for the given mode.
|
|
|
|
Most styling is handled by Textual's built-in CSS variables (`$primary`,
|
|
`$text-muted`, `$error-muted`, etc.). This function only returns
|
|
app-specific semantic variables that have no Textual equivalent.
|
|
|
|
Args:
|
|
dark: Selects `DARK_COLORS` or `LIGHT_COLORS` when `colors` is None.
|
|
colors: Explicit color set to use. Takes precedence over `dark`.
|
|
|
|
Returns:
|
|
Dict of CSS variable names to hex color values.
|
|
"""
|
|
c = colors if colors is not None else (DARK_COLORS if dark else LIGHT_COLORS)
|
|
return {
|
|
"mode-bash": c.mode_bash,
|
|
"mode-command": c.mode_command,
|
|
"mode-incognito": c.mode_incognito,
|
|
"skill": c.skill,
|
|
"skill-hover": c.skill_hover,
|
|
"tool": c.tool,
|
|
"tool-hover": c.tool_hover,
|
|
}
|
|
|
|
|
|
def _resolve_app(widget_or_app: object) -> object:
|
|
"""Resolve a widget or App to the App instance.
|
|
|
|
Args:
|
|
widget_or_app: Textual `App` or a mounted widget.
|
|
|
|
Returns:
|
|
The resolved App instance.
|
|
"""
|
|
return (
|
|
widget_or_app.app # ty: ignore[unresolved-attribute]
|
|
if hasattr(type(widget_or_app), "app")
|
|
else widget_or_app
|
|
)
|
|
|
|
|
|
def _colors_from_textual_theme(app: object) -> ThemeColors:
|
|
"""Construct `ThemeColors` from the app's active Textual theme.
|
|
|
|
Reads standard properties (primary, secondary, etc.) from the resolved
|
|
theme so Python-side styling matches CSS. `muted` and `mode_incognito`
|
|
have no Textual equivalent and always source from the dark/light base
|
|
palette. `mode_bash` is derived from the theme's `error` color and
|
|
`mode_command` from `secondary`, both falling back to the base palette
|
|
when non-hex values (e.g. `ansi_blue` in the ANSI theme) are detected.
|
|
|
|
Args:
|
|
app: The Textual App instance.
|
|
|
|
Returns:
|
|
`ThemeColors` derived from the active theme.
|
|
"""
|
|
ct = app.current_theme # ty: ignore[unresolved-attribute]
|
|
dark: bool = ct.dark
|
|
base = DARK_COLORS if dark else LIGHT_COLORS
|
|
|
|
def _hex_or(val: str | None, fallback: str) -> str:
|
|
"""Return `val` if it is a valid `#RRGGBB` hex color, else `fallback`.
|
|
|
|
Args:
|
|
val: Color string from the active Textual theme (may be `None` or
|
|
a non-hex name like `ansi_blue`).
|
|
fallback: Guaranteed-hex value from our base palette.
|
|
|
|
Returns:
|
|
`val` if it matches `#RRGGBB`, otherwise `fallback`.
|
|
"""
|
|
if val is not None and _HEX_RE.match(val):
|
|
return val
|
|
return fallback
|
|
|
|
return ThemeColors(
|
|
primary=_hex_or(ct.primary, base.primary),
|
|
secondary=_hex_or(ct.secondary, base.secondary),
|
|
accent=_hex_or(ct.accent, base.accent),
|
|
panel=_hex_or(ct.panel, base.panel),
|
|
success=_hex_or(ct.success, base.success),
|
|
warning=_hex_or(ct.warning, base.warning),
|
|
error=_hex_or(ct.error, base.error),
|
|
muted=base.muted,
|
|
mode_bash=_hex_or(ct.error, base.mode_bash),
|
|
mode_command=_hex_or(ct.secondary, base.mode_command),
|
|
mode_incognito=base.mode_incognito,
|
|
# No Textual equivalent — always use base palette.
|
|
skill=base.skill,
|
|
skill_hover=base.skill_hover,
|
|
# Derived from Textual's warning color (shared amber hue).
|
|
tool=_hex_or(ct.warning, base.tool),
|
|
# No Textual equivalent — always base palette (may diverge from
|
|
# tool in custom themes that override warning).
|
|
tool_hover=base.tool_hover,
|
|
foreground=_hex_or(ct.foreground, base.foreground),
|
|
background=_hex_or(ct.background, base.background),
|
|
surface=_hex_or(ct.surface, base.surface),
|
|
)
|
|
|
|
|
|
def get_theme_colors(widget_or_app: App | object | None = None) -> ThemeColors:
|
|
"""Return the `ThemeColors` for the active Textual theme.
|
|
|
|
For custom themes (LangChain-branded and user-defined), the pre-built
|
|
`ThemeColors` from the registry is returned directly. For Textual built-in
|
|
themes, colors are resolved dynamically from the actual theme properties so
|
|
Python-side styling stays in sync with CSS variables.
|
|
|
|
Textual widget code should call this instead of reading the module-level
|
|
ANSI constants, which are intended for Rich console output only.
|
|
|
|
Args:
|
|
widget_or_app: Textual `App`, a mounted widget, or `None`.
|
|
|
|
Returns:
|
|
`ThemeColors` for the active theme.
|
|
"""
|
|
if widget_or_app is None:
|
|
# Fall back to the active Textual app context var when no explicit
|
|
# widget/app is passed (e.g. from @staticmethod helpers).
|
|
try:
|
|
from textual._context import active_app # noqa: PLC2701
|
|
|
|
widget_or_app = active_app.get()
|
|
except (ImportError, LookupError):
|
|
return DARK_COLORS
|
|
app = _resolve_app(widget_or_app)
|
|
entry = get_registry().get(app.theme) # ty: ignore[unresolved-attribute]
|
|
# Custom themes (LC-branded / user-defined) use pre-built colors.
|
|
if entry is not None and entry.custom:
|
|
return entry.colors
|
|
# Built-in or unrecognized themes — derive from the resolved Textual
|
|
# theme so Python styling matches CSS. Cache only registered built-ins,
|
|
# since unregistered runtime themes may reuse a name with different colors.
|
|
try:
|
|
ct = app.current_theme # ty: ignore[unresolved-attribute]
|
|
if entry is None:
|
|
colors = _colors_from_textual_theme(app)
|
|
else:
|
|
key = (app.theme, bool(ct.dark)) # ty: ignore[unresolved-attribute]
|
|
colors = _textual_colors_cache.get(key)
|
|
if colors is None:
|
|
colors = _colors_from_textual_theme(app)
|
|
_textual_colors_cache[key] = colors
|
|
except Exception:
|
|
logger.warning("Could not resolve theme colors dynamically", exc_info=True)
|
|
if entry is not None:
|
|
return entry.colors
|
|
return DARK_COLORS
|
|
else:
|
|
return colors
|