mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(cli): per-terminal theme selection (#3248)
Fixes part 1 of #2146 --- Add per-terminal theme mapping so users switching between terminals (e.g. dark iTerm vs. light Apple Terminal) get the right theme automatically without re-picking each time. ## Changes - Add `[ui.terminal_themes]` config table keyed on `TERM_PROGRAM` — looked up before the saved global preference in `_load_theme_preference`, after the `DEEPAGENTS_CLI_THEME` env var override - Introduce `_resolve_theme_name` to canonicalize theme values (registry key, human label, case-insensitive, whitespace-tolerant, plus `textual-ansi` → `ansi-light` legacy migration) shared across env var, terminal mapping, and saved-preference resolution paths - Add `save_terminal_theme_mapping` for atomic TOML writes to `[ui.terminal_themes]`; preserves existing table entries and repairs malformed scalar values with a warning - Extend `ThemeSelectorScreen` with `n` to toggle between human-readable labels and canonical registry keys, and `t` to persist the highlighted theme for the current `TERM_PROGRAM` without touching `[ui].theme` — the picker stays open and the `(default)` badge updates in place, so users can keep browsing - Badge the option list with `(default)` when a theme matches the current terminal's saved mapping; combine with `(current)` as `(current, default)` when both apply - Serialize `config.toml` writes with a process-local lock so a quick `t`-then-Enter sequence can't clobber either mutation PR 1 of 2 — system dark/light auto-detection will follow once Textualize/textual#6151 lands upstream. --------- Co-authored-by: Mason Daugherty <github@mdrxy.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
+389
-58
@@ -9,6 +9,7 @@ import os
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import webbrowser
|
||||
@@ -91,10 +92,30 @@ from deepagents_cli.widgets.welcome import WelcomeBanner
|
||||
logger = logging.getLogger(__name__)
|
||||
_monotonic = time.monotonic
|
||||
|
||||
# Serializes process-local read-modify-write operations for `config.toml`.
|
||||
# Without this, overlapping global-theme and per-terminal-theme saves can each
|
||||
# read the same pre-mutation state and then clobber the other's keys.
|
||||
_CONFIG_WRITE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ConfigWriteResult:
|
||||
"""Result of a config write with TUI-facing failure context."""
|
||||
|
||||
ok: bool
|
||||
"""Whether the write completed successfully."""
|
||||
|
||||
message: str | None = None
|
||||
"""Optional user-facing detail for repairs or failures."""
|
||||
|
||||
severity: Literal["warning", "error"] = "warning"
|
||||
"""Toast severity to use when `message` is shown."""
|
||||
|
||||
|
||||
ScreenResultT = TypeVar("ScreenResultT")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
|
||||
from deepagents.backends import CompositeBackend
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
@@ -128,9 +149,160 @@ _UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
|
||||
"""How often long-running TUI sessions quietly re-check for CLI updates."""
|
||||
|
||||
|
||||
def _resolve_theme_name(value: object) -> str | None:
|
||||
"""Resolve a user-supplied theme name to a canonical registry key.
|
||||
|
||||
Accepts the registry key or the human-readable label, case-insensitive
|
||||
on both, with surrounding whitespace stripped — config values
|
||||
(especially `[ui.terminal_themes]`) and the `DEEPAGENTS_CLI_THEME`
|
||||
env var are commonly hand-edited. Also applies the legacy
|
||||
`textual-ansi` → `ansi-light` migration (pre-Textual 8.2.5).
|
||||
|
||||
Args:
|
||||
value: Raw value read from TOML or an environment variable.
|
||||
|
||||
Returns:
|
||||
The canonical registry key, or `None` if the value is not a string or
|
||||
does not match any registered theme by key or label
|
||||
(case-insensitive).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
name = value.strip()
|
||||
if name == "textual-ansi":
|
||||
name = "ansi-light"
|
||||
registry = theme.get_registry()
|
||||
if name in registry:
|
||||
return name
|
||||
folded = name.casefold()
|
||||
for registered, entry in registry.items():
|
||||
if registered.casefold() == folded or entry.label.casefold() == folded:
|
||||
return registered
|
||||
return None
|
||||
|
||||
|
||||
def _as_toml_table(value: object) -> dict[str, object] | None:
|
||||
"""Return `value` as a TOML table when it has the expected runtime shape."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
# `tomllib` parses TOML tables as string-keyed dicts; `ty` cannot infer
|
||||
# that from a runtime `dict` check. Keep the cast at this boundary so it
|
||||
# does not become a general-purpose escape hatch.
|
||||
from typing import cast
|
||||
|
||||
return cast("dict[str, object]", value)
|
||||
|
||||
|
||||
def _resolve_terminal_mapping(ui: Mapping[str, object]) -> str | None:
|
||||
"""Resolve `[ui.terminal_themes][TERM_PROGRAM]` to a registered theme.
|
||||
|
||||
Centralizes both the lookup and the misconfiguration warnings shared by
|
||||
`_load_theme_preference` (startup) and `_load_terminal_default` (picker
|
||||
badge). Misconfiguration is logged exactly once per call.
|
||||
|
||||
Args:
|
||||
ui: The `[ui]` table parsed from `config.toml`.
|
||||
|
||||
Returns:
|
||||
The canonical registry key, or `None` if `terminal_themes` is absent,
|
||||
malformed, references an unknown theme, or `TERM_PROGRAM` is unset
|
||||
despite a non-empty mapping.
|
||||
"""
|
||||
terminal_themes = ui.get("terminal_themes")
|
||||
if terminal_themes is None:
|
||||
return None
|
||||
terminal_themes_table = _as_toml_table(terminal_themes)
|
||||
if terminal_themes_table is None:
|
||||
logger.warning(
|
||||
"[ui.terminal_themes] should be a table mapping TERM_PROGRAM "
|
||||
"values to theme names; got %s",
|
||||
type(terminal_themes).__name__,
|
||||
)
|
||||
return None
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").strip()
|
||||
if not term_program:
|
||||
if terminal_themes_table:
|
||||
logger.warning(
|
||||
"[ui.terminal_themes] is configured but TERM_PROGRAM is unset; "
|
||||
"no per-terminal theme will be applied",
|
||||
)
|
||||
return None
|
||||
mapped = terminal_themes_table.get(term_program)
|
||||
resolved = _resolve_theme_name(mapped)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
if isinstance(mapped, str):
|
||||
logger.warning(
|
||||
"Unknown theme '%s' mapped to TERM_PROGRAM='%s' "
|
||||
"in [ui.terminal_themes]; ignoring",
|
||||
mapped,
|
||||
term_program,
|
||||
)
|
||||
elif mapped is not None:
|
||||
logger.warning(
|
||||
"Expected string theme name for TERM_PROGRAM='%s' in "
|
||||
"[ui.terminal_themes], got %s; ignoring",
|
||||
term_program,
|
||||
type(mapped).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _load_terminal_default() -> str | None:
|
||||
"""Return the saved default theme for the current `TERM_PROGRAM`.
|
||||
|
||||
Reads `[ui.terminal_themes][TERM_PROGRAM]` from `config.toml` and
|
||||
resolves the value via `_resolve_theme_name`, so labels and case variants
|
||||
are accepted. Used by `ThemeSelectorScreen` to badge the matching option
|
||||
with `(default)`.
|
||||
|
||||
Returns:
|
||||
The canonical registry key, or `None` if `TERM_PROGRAM` is unset, the
|
||||
file is missing/unreadable, no mapping is set, or the mapped value
|
||||
doesn't match a registered theme. Read errors and misconfigurations
|
||||
are logged at WARNING.
|
||||
"""
|
||||
if not os.environ.get("TERM_PROGRAM", "").strip():
|
||||
return None
|
||||
|
||||
import tomllib
|
||||
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
if not DEFAULT_CONFIG_PATH.exists():
|
||||
return None
|
||||
try:
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
||||
logger.warning("Could not read config for terminal theme default: %s", exc)
|
||||
return None
|
||||
|
||||
ui = data.get("ui")
|
||||
if not isinstance(ui, dict):
|
||||
if ui is not None:
|
||||
logger.warning(
|
||||
"[ui] should be a table; got %s while loading terminal theme default",
|
||||
type(ui).__name__,
|
||||
)
|
||||
return None
|
||||
return _resolve_terminal_mapping(ui)
|
||||
|
||||
|
||||
def _load_theme_preference() -> str:
|
||||
"""Load the forced or saved theme name, or return the default.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `DEEPAGENTS_CLI_THEME` env var (explicit override). If it is set but
|
||||
cannot be resolved, the default theme is used immediately.
|
||||
2. `[ui.terminal_themes]` mapping keyed by `TERM_PROGRAM` — wins over the
|
||||
saved preference so a user moving between terminals (e.g. dark iTerm,
|
||||
light Apple Terminal) gets the right theme automatically.
|
||||
3. `[ui].theme` in `~/.deepagents/config.toml` (saved preference, used
|
||||
when no terminal mapping matches).
|
||||
4. `theme.DEFAULT_THEME`.
|
||||
|
||||
Returns:
|
||||
A Textual theme name (e.g., `'langchain'`, `'langchain-light'`).
|
||||
"""
|
||||
@@ -138,16 +310,9 @@ def _load_theme_preference() -> str:
|
||||
|
||||
env_name = os.environ.get(THEME)
|
||||
if env_name is not None:
|
||||
name = env_name.strip()
|
||||
registry = theme.get_registry()
|
||||
if name in registry:
|
||||
return name
|
||||
for registered, entry in registry.items():
|
||||
if (
|
||||
registered.casefold() == name.casefold()
|
||||
or entry.label.casefold() == name.casefold()
|
||||
):
|
||||
return registered
|
||||
resolved = _resolve_theme_name(env_name)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
logger.warning(
|
||||
"Unknown theme '%s' in %s; falling back to default",
|
||||
env_name,
|
||||
@@ -157,32 +322,125 @@ def _load_theme_preference() -> str:
|
||||
|
||||
import tomllib
|
||||
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
if not DEFAULT_CONFIG_PATH.exists():
|
||||
return theme.DEFAULT_THEME
|
||||
try:
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
if not DEFAULT_CONFIG_PATH.exists():
|
||||
return theme.DEFAULT_THEME
|
||||
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
||||
logger.warning("Could not read config for theme preference: %s", exc)
|
||||
return theme.DEFAULT_THEME
|
||||
|
||||
name = data.get("ui", {}).get("theme")
|
||||
# Migrate legacy `textual-ansi` preference (pre-Textual 8.2.5) to `ansi-light`.
|
||||
if name == "textual-ansi":
|
||||
name = "ansi-light"
|
||||
if isinstance(name, str) and name in theme.get_registry():
|
||||
return name
|
||||
if isinstance(name, str):
|
||||
ui = data.get("ui", {})
|
||||
if not isinstance(ui, dict):
|
||||
logger.warning(
|
||||
"[ui] should be a table; got %s while loading theme preference",
|
||||
type(ui).__name__,
|
||||
)
|
||||
return theme.DEFAULT_THEME
|
||||
|
||||
resolved = _resolve_terminal_mapping(ui)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
saved = ui.get("theme")
|
||||
resolved = _resolve_theme_name(saved)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
if isinstance(saved, str):
|
||||
logger.warning(
|
||||
"Unknown theme '%s' in config; falling back to default",
|
||||
name,
|
||||
saved,
|
||||
)
|
||||
|
||||
return theme.DEFAULT_THEME
|
||||
|
||||
|
||||
def _replace_malformed_ui(
|
||||
data: dict[str, object],
|
||||
) -> tuple[dict[str, object], str | None]:
|
||||
"""Return a writable `[ui]` table, replacing malformed values if needed."""
|
||||
ui = data.get("ui")
|
||||
table = _as_toml_table(ui)
|
||||
if table is not None:
|
||||
return table, None
|
||||
replaced_malformed = ui is not None
|
||||
if ui is not None:
|
||||
logger.warning(
|
||||
"Existing [ui] is not a table (got %r); replacing with a fresh table",
|
||||
ui,
|
||||
)
|
||||
ui = {}
|
||||
data["ui"] = ui
|
||||
return ui, (
|
||||
"Existing [ui] was not a table and was replaced while saving the theme "
|
||||
"configuration."
|
||||
if replaced_malformed
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _save_theme_preference_result(name: str) -> _ConfigWriteResult:
|
||||
"""Persist theme preference and return TUI-facing status details.
|
||||
|
||||
Returns:
|
||||
Write status and a message suitable for a toast when the user needs to
|
||||
know about a repair or failure.
|
||||
"""
|
||||
if name not in theme.get_registry():
|
||||
logger.warning("Refusing to save unknown theme '%s'", name)
|
||||
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
|
||||
|
||||
import contextlib
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _CONFIG_WRITE_LOCK:
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
ui, repair_message = _replace_malformed_ui(data)
|
||||
ui["theme"] = name
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=DEFAULT_CONFIG_PATH.parent, suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp_path).unlink()
|
||||
raise
|
||||
except (
|
||||
OSError,
|
||||
tomllib.TOMLDecodeError,
|
||||
ImportError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
logger.exception("Could not save theme preference")
|
||||
return _ConfigWriteResult(
|
||||
False,
|
||||
f"Theme applied for this session but could not be saved "
|
||||
f"({type(exc).__name__}).",
|
||||
"error",
|
||||
)
|
||||
return _ConfigWriteResult(True, repair_message)
|
||||
|
||||
|
||||
def save_theme_preference(name: str) -> bool:
|
||||
"""Persist theme preference to `~/.deepagents/config.toml`.
|
||||
|
||||
@@ -192,44 +450,114 @@ def save_theme_preference(name: str) -> bool:
|
||||
Returns:
|
||||
`True` if the preference was saved, `False` if any error occurred.
|
||||
"""
|
||||
return _save_theme_preference_result(name).ok
|
||||
|
||||
|
||||
def _save_terminal_theme_mapping_result(
|
||||
term_program: str, name: str
|
||||
) -> _ConfigWriteResult:
|
||||
"""Persist a terminal theme mapping and return TUI-facing status details.
|
||||
|
||||
Returns:
|
||||
Write status and a message suitable for a toast when the user needs to
|
||||
know about a repair or failure.
|
||||
"""
|
||||
if name not in theme.get_registry():
|
||||
logger.warning("Refusing to save unknown theme '%s'", name)
|
||||
return False
|
||||
logger.warning("Refusing to map unknown theme '%s'", name)
|
||||
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
|
||||
term_program = term_program.strip()
|
||||
if not term_program:
|
||||
logger.warning("Refusing to save terminal mapping with empty TERM_PROGRAM")
|
||||
return _ConfigWriteResult(
|
||||
False,
|
||||
"TERM_PROGRAM is unset; can't set a per-terminal default.",
|
||||
)
|
||||
|
||||
import contextlib
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
|
||||
import tomli_w
|
||||
|
||||
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH
|
||||
|
||||
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
else:
|
||||
data = {}
|
||||
repair_messages: list[str] = []
|
||||
with _CONFIG_WRITE_LOCK:
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
if "ui" not in data:
|
||||
data["ui"] = {}
|
||||
data["ui"]["theme"] = name
|
||||
ui, repair_message = _replace_malformed_ui(data)
|
||||
if repair_message is not None:
|
||||
repair_messages.append(repair_message)
|
||||
terminal_themes = ui.get("terminal_themes")
|
||||
terminal_themes_table = _as_toml_table(terminal_themes)
|
||||
if terminal_themes_table is None:
|
||||
if terminal_themes is not None:
|
||||
logger.warning(
|
||||
"Existing [ui.terminal_themes] is not a table (got %r); "
|
||||
"replacing with a fresh table",
|
||||
terminal_themes,
|
||||
)
|
||||
repair_messages.append(
|
||||
"Existing [ui.terminal_themes] was not a table and was "
|
||||
"replaced while saving this terminal default."
|
||||
)
|
||||
terminal_themes_table = {}
|
||||
ui["terminal_themes"] = terminal_themes_table
|
||||
terminal_themes_table[term_program] = name
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=DEFAULT_CONFIG_PATH.parent, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp_path).unlink()
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Could not save theme preference")
|
||||
return False
|
||||
return True
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=DEFAULT_CONFIG_PATH.parent, suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp_path).unlink()
|
||||
raise
|
||||
except (
|
||||
OSError,
|
||||
tomllib.TOMLDecodeError,
|
||||
ImportError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
logger.exception("Could not save terminal theme mapping")
|
||||
return _ConfigWriteResult(
|
||||
False,
|
||||
f"Could not save terminal mapping ({type(exc).__name__}).",
|
||||
"error",
|
||||
)
|
||||
return _ConfigWriteResult(True, " ".join(repair_messages) or None)
|
||||
|
||||
|
||||
def save_terminal_theme_mapping(term_program: str, name: str) -> bool:
|
||||
"""Persist a `[ui.terminal_themes][term_program] = name` entry.
|
||||
|
||||
The write is atomic (temp file + `Path.replace`) to avoid corrupting
|
||||
`config.toml` on crash or SIGINT. Mirrors `save_theme_preference`.
|
||||
|
||||
Args:
|
||||
term_program: Value of the `TERM_PROGRAM` environment variable to key
|
||||
on. Whitespace is stripped; the trimmed value is matched verbatim
|
||||
against `os.environ["TERM_PROGRAM"]` at lookup time.
|
||||
name: Theme name to map. Validated as an exact registry-key match —
|
||||
labels and case variants are rejected here because the picker
|
||||
writes canonical keys.
|
||||
|
||||
Returns:
|
||||
`True` if the mapping was saved, `False` if `name` isn't a registered
|
||||
theme, `term_program` is empty after stripping, or any error
|
||||
occurred.
|
||||
"""
|
||||
return _save_terminal_theme_mapping_result(term_program, name).ok
|
||||
|
||||
|
||||
def _extract_model_params_flag(raw_arg: str) -> tuple[str, dict[str, Any] | None]:
|
||||
@@ -5690,7 +6018,7 @@ class DeepAgentsApp(App):
|
||||
This method also stores the message data and handles pruning
|
||||
when the widget count exceeds the maximum.
|
||||
|
||||
If the ``#messages`` container is not present (e.g. the screen has
|
||||
If the `#messages` container is not present (e.g. the screen has
|
||||
been torn down during an interruption), the call is silently skipped
|
||||
to avoid cascading `NoMatches` errors.
|
||||
|
||||
@@ -6589,12 +6917,13 @@ class DeepAgentsApp(App):
|
||||
|
||||
async def _persist() -> None:
|
||||
try:
|
||||
ok = await asyncio.to_thread(save_theme_preference, result)
|
||||
if not ok:
|
||||
status = await asyncio.to_thread(
|
||||
_save_theme_preference_result, result
|
||||
)
|
||||
if status.message is not None:
|
||||
self.notify(
|
||||
"Theme applied for this session but could not"
|
||||
" be saved. Check logs for details.",
|
||||
severity="warning",
|
||||
status.message,
|
||||
severity=status.severity,
|
||||
timeout=6,
|
||||
markup=False,
|
||||
)
|
||||
@@ -6604,8 +6933,7 @@ class DeepAgentsApp(App):
|
||||
exc_info=True,
|
||||
)
|
||||
self.notify(
|
||||
"Theme applied for this session but could not"
|
||||
" be saved. Check logs for details.",
|
||||
"Theme applied for this session but could not be saved.",
|
||||
severity="warning",
|
||||
timeout=6,
|
||||
markup=False,
|
||||
@@ -6619,7 +6947,10 @@ class DeepAgentsApp(App):
|
||||
if self._chat_input:
|
||||
self._chat_input.focus_input()
|
||||
|
||||
screen = ThemeSelectorScreen(current_theme=self.theme)
|
||||
screen = ThemeSelectorScreen(
|
||||
current_theme=self.theme,
|
||||
terminal_default=_load_terminal_default(),
|
||||
)
|
||||
self.push_screen(screen, handle_result)
|
||||
|
||||
async def _show_agent_selector(self) -> None:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
@@ -32,6 +34,8 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
Binding("tab", "cursor_down", "Next", show=False, priority=True),
|
||||
Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
|
||||
Binding("n", "toggle_names", "Names", show=False),
|
||||
Binding("t", "set_for_terminal", "Set for terminal", show=False),
|
||||
]
|
||||
"""Key bindings for the selector.
|
||||
|
||||
@@ -39,6 +43,10 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
handled natively by the embedded `OptionList`; Tab / Shift+Tab are bound
|
||||
here to advance the option list cursor for consistency with other
|
||||
selector screens (where Tab cycles focus across multiple widgets).
|
||||
`action_toggle_names` toggles between human-readable labels and canonical
|
||||
registry keys, which are accepted by the theme config. The terminal-default
|
||||
action saves the highlighted theme for the current terminal and updates
|
||||
the `(default)` badge in place without closing the picker.
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
@@ -71,7 +79,7 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
}
|
||||
|
||||
ThemeSelectorScreen .theme-selector-help {
|
||||
height: 1;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
@@ -80,15 +88,42 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
"""
|
||||
"""Styling for the centered modal shell, title, option list, and help footer."""
|
||||
|
||||
def __init__(self, current_theme: str) -> None:
|
||||
def __init__(self, current_theme: str, terminal_default: str | None = None) -> None:
|
||||
"""Initialize the ThemeSelectorScreen.
|
||||
|
||||
Args:
|
||||
current_theme: The currently active theme name (to highlight).
|
||||
terminal_default: The theme saved in `[ui.terminal_themes]` for
|
||||
the current `TERM_PROGRAM`, if any. Badged with `(default)`
|
||||
in the option list.
|
||||
"""
|
||||
super().__init__()
|
||||
self._current_theme = current_theme
|
||||
self._original_theme = current_theme
|
||||
self._terminal_default = terminal_default
|
||||
self._show_keys = False
|
||||
|
||||
def _format_option(self, name: str, entry: theme.ThemeEntry) -> str:
|
||||
"""Render the option text for a theme entry.
|
||||
|
||||
Args:
|
||||
name: Registry key.
|
||||
entry: Registry entry.
|
||||
|
||||
Returns:
|
||||
Either the human label or the registry key, with `(current)`
|
||||
and/or `(default)` suffixes — combined as
|
||||
`(current, default)` when both apply to the same theme.
|
||||
"""
|
||||
text = name if self._show_keys else entry.label
|
||||
suffixes: list[str] = []
|
||||
if name == self._current_theme:
|
||||
suffixes.append("current")
|
||||
if name == self._terminal_default:
|
||||
suffixes.append("default")
|
||||
if suffixes:
|
||||
text = f"{text} ({', '.join(suffixes)})"
|
||||
return text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the screen layout.
|
||||
@@ -101,23 +136,22 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
highlight_index = 0
|
||||
|
||||
for i, (name, entry) in enumerate(theme.get_registry().items()):
|
||||
label = entry.label
|
||||
options.append(Option(self._format_option(name, entry), id=name))
|
||||
if name == self._current_theme:
|
||||
label = f"{label} (current)"
|
||||
highlight_index = i
|
||||
options.append(Option(label, id=name))
|
||||
|
||||
with Vertical():
|
||||
yield Static("Select Theme", classes="theme-selector-title")
|
||||
option_list = OptionList(*options, id="theme-options")
|
||||
option_list.highlighted = highlight_index
|
||||
yield option_list
|
||||
help_text = (
|
||||
nav_line = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f" {glyphs.bullet} Enter select"
|
||||
f" {glyphs.bullet} Esc cancel"
|
||||
)
|
||||
yield Static(help_text, classes="theme-selector-help")
|
||||
action_line = f"N labels/keys {glyphs.bullet} T set for this terminal"
|
||||
yield Static(f"{nav_line}\n{action_line}", classes="theme-selector-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Apply ASCII border if needed."""
|
||||
@@ -180,3 +214,118 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
def action_cursor_up(self) -> None:
|
||||
"""Move the option list cursor up (Shift+Tab)."""
|
||||
self.query_one(OptionList).action_cursor_up()
|
||||
|
||||
def action_set_for_terminal(self) -> None:
|
||||
"""Persist the highlighted theme as the default for `TERM_PROGRAM`.
|
||||
|
||||
Writes `[ui.terminal_themes][TERM_PROGRAM] = name` and updates the
|
||||
`(default)` badge in the option list without closing the picker, so
|
||||
the user can confirm the change and keep browsing. `[ui].theme` is
|
||||
intentionally not touched because this action saves only the current
|
||||
terminal default. Config writes are serialized in `app.py`, so
|
||||
overlapping global-theme and per-terminal-theme saves cannot clobber
|
||||
each other's keys.
|
||||
|
||||
No-ops with a warning toast if `TERM_PROGRAM` is unset, or silently
|
||||
if the option list has no highlighted entry / the highlighted id
|
||||
isn't a registered theme.
|
||||
"""
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").strip()
|
||||
if not term_program:
|
||||
self.app.notify(
|
||||
"TERM_PROGRAM is unset; can't set a per-terminal default. "
|
||||
"Set the [ui].theme directly with Enter.",
|
||||
severity="warning",
|
||||
markup=False,
|
||||
timeout=6,
|
||||
)
|
||||
return
|
||||
|
||||
option_list = self.query_one(OptionList)
|
||||
if option_list.highlighted is None:
|
||||
logger.warning("action_set_for_terminal invoked with no highlighted option")
|
||||
return
|
||||
option = option_list.get_option_at_index(option_list.highlighted)
|
||||
name = option.id
|
||||
if name is None or name not in theme.get_registry():
|
||||
logger.warning(
|
||||
"action_set_for_terminal got unregistered option id '%s'", name
|
||||
)
|
||||
return
|
||||
|
||||
async def _persist() -> None:
|
||||
try:
|
||||
from deepagents_cli.app import _save_terminal_theme_mapping_result
|
||||
|
||||
status = await asyncio.to_thread(
|
||||
_save_terminal_theme_mapping_result, term_program, name
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to persist terminal theme mapping")
|
||||
self.app.notify(
|
||||
f"Could not save terminal mapping ({type(exc).__name__}).",
|
||||
severity="error",
|
||||
markup=False,
|
||||
timeout=6,
|
||||
)
|
||||
return
|
||||
if not status.ok:
|
||||
self.app.notify(
|
||||
status.message or "Could not save terminal mapping.",
|
||||
severity=status.severity,
|
||||
markup=False,
|
||||
timeout=6,
|
||||
)
|
||||
return
|
||||
if status.message is not None:
|
||||
self.app.notify(
|
||||
status.message,
|
||||
severity=status.severity,
|
||||
markup=False,
|
||||
timeout=6,
|
||||
)
|
||||
# Update the badge in place if the screen is still mounted.
|
||||
# The user may have dismissed the picker (Esc/Enter) while the
|
||||
# write was in flight; `is_mounted` guards the widget tree.
|
||||
if self.is_mounted:
|
||||
self._terminal_default = name
|
||||
self._rerender_options()
|
||||
self.app.notify(
|
||||
f"Set '{name}' as the default for {term_program}.",
|
||||
severity="information",
|
||||
markup=False,
|
||||
timeout=4,
|
||||
)
|
||||
|
||||
# Anchor the worker on the app, not this screen — if the user
|
||||
# dismisses the picker mid-flight, the screen tears down its own
|
||||
# workers but the write should still complete and toast.
|
||||
self.app.run_worker(_persist(), exclusive=False)
|
||||
|
||||
def action_toggle_names(self) -> None:
|
||||
"""Toggle between human labels and registry keys in the option list.
|
||||
|
||||
Useful for copying the canonical key into `[ui.terminal_themes]` or
|
||||
`[ui].theme` without leaving the picker.
|
||||
"""
|
||||
self._show_keys = not self._show_keys
|
||||
self._rerender_options()
|
||||
|
||||
def _rerender_options(self) -> None:
|
||||
"""Rebuild the option list, preserving the cursor position.
|
||||
|
||||
Used when the badge text or label/key mode changes — Textual's
|
||||
`OptionList` doesn't expose a way to mutate a rendered prompt, so
|
||||
we recreate the options.
|
||||
"""
|
||||
option_list = self.query_one(OptionList)
|
||||
cursor = option_list.highlighted
|
||||
registry = theme.get_registry()
|
||||
new_options = [
|
||||
Option(self._format_option(name, entry), id=name)
|
||||
for name, entry in registry.items()
|
||||
]
|
||||
option_list.clear_options()
|
||||
option_list.add_options(new_options)
|
||||
if cursor is not None:
|
||||
option_list.highlighted = cursor
|
||||
|
||||
@@ -49,6 +49,7 @@ _TIPS: dict[str, int] = {
|
||||
"Use /skill:<name> to invoke a skill directly": 1,
|
||||
"Type /update to check for and install updates": 1,
|
||||
"Use /theme to customize the CLI colors and style": 1,
|
||||
"In /theme, press N to toggle labels/keys, T to set for the current terminal": 1,
|
||||
"Use /skill-creator to build reusable agent skills": 1,
|
||||
"Use /auto-update to toggle automatic CLI updates": 1,
|
||||
"Use /agents to browse and switch between your available agents": 2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user