diff --git a/insto/repl.py b/insto/repl.py index d0aab99..1259010 100644 --- a/insto/repl.py +++ b/insto/repl.py @@ -45,7 +45,7 @@ from insto.config import Config, cli_history_path, load_config from insto.exceptions import BackendError from insto.ui.banner import render_welcome -from insto.ui.theme import get_palette, get_theme, list_themes +from insto.ui.theme import get_palette, get_theme, list_themes, theme_description if TYPE_CHECKING: from prompt_toolkit.key_binding import KeyPressEvent @@ -95,6 +95,11 @@ def _first_positional_choices(spec: Any) -> tuple[str, ...]: return () +def _theme_label(name: str) -> FormattedText: + """Theme name rendered in its own accent colour (for completion display).""" + return FormattedText([(f"fg:{get_palette(name).accent}", name)]) + + class _SlashCommandCompleter(Completer): """Slack/Claude-Code-style command completer. @@ -165,12 +170,13 @@ def get_completions( spec = COMMANDS.get(user_typed) if spec is not None: + is_theme = user_typed == "theme" for choice in _first_positional_choices(spec): yield Completion( text=f"{prefix} {choice}", start_position=-len(prefix), - display=choice, - display_meta="", + display=_theme_label(choice) if is_theme else choice, + display_meta=theme_description(choice) if is_theme else "", ) def _argument_completions(self, text: str, stripped: str) -> Iterable[Completion]: @@ -209,6 +215,7 @@ def _argument_completions(self, text: str, stripped: str) -> Iterable[Completion if not choices: return lower = current_word.lower() + is_theme = cmd_name == "theme" for choice in choices: s = str(choice) if not s.lower().startswith(lower): @@ -216,8 +223,12 @@ def _argument_completions(self, text: str, stripped: str) -> Iterable[Completion yield Completion( text=s, start_position=-len(current_word), - display=s, - display_meta=str(action.help or ""), + # Themes render their name in their own accent colour + # with a per-theme description, so the popup hints at + # the look before you commit (full preview lives in the + # bare-`/theme` picker). + display=_theme_label(s) if is_theme else s, + display_meta=theme_description(s) if is_theme else str(action.help or ""), ) return pos_index += 1 @@ -415,7 +426,10 @@ def banner() -> ANSI: def footer() -> ANSI: name = themes[state["i"]] - return ANSI(f"\n ↑/↓ preview · Enter apply · Esc cancel theme: {name}") + return ANSI( + f"\n ↑/↓ preview · Enter apply · Esc cancel " + f"theme: {name} — {theme_description(name)}" + ) kb = KeyBindings() diff --git a/insto/ui/theme.py b/insto/ui/theme.py index a896fcc..e75abb9 100644 --- a/insto/ui/theme.py +++ b/insto/ui/theme.py @@ -133,6 +133,23 @@ def _make_theme(p: _Palette) -> Theme: } +# One-line descriptions surfaced in /theme completion and the picker. Keep a +# key for every palette above (guarded by tests/test_theme.py). +_DESCRIPTIONS: dict[str, str] = { + "aiograpi": "instagrapi violet → blue (default)", + "claude": "Claude Code burnt orange", + "instagram": "Instagram brand gradient", + "hacker": "matrix phosphor green on black", + "amber": "80s amber CRT phosphor", + "cyberpunk": "neon cyan → magenta → green", +} + + +def theme_description(name: str | None) -> str: + """One-line description of a theme (for /theme completion + picker footer).""" + return _DESCRIPTIONS.get(name or DEFAULT_THEME_NAME, "") + + THEMES: dict[str, Theme] = {name: _make_theme(p) for name, p in _PALETTES.items()} DEFAULT_THEME_NAME: str = "aiograpi" diff --git a/tests/test_repl_completer.py b/tests/test_repl_completer.py index a0d02fb..e80c068 100644 --- a/tests/test_repl_completer.py +++ b/tests/test_repl_completer.py @@ -393,3 +393,16 @@ async def runner() -> None: asyncio.run(runner()) out = _console.export_text(styles=False) assert "unknown command" in out + + +def test_theme_arg_completions_have_per_theme_meta() -> None: + # `/theme ` shows each theme with its OWN description (not one generic + # argparse-help line repeated for every choice). + completer = _completer() + doc = Document(text="/theme ", cursor_position=len("/theme ")) + comps = list(completer.get_completions(doc, complete_event=None)) # type: ignore[arg-type] + metas = {c.text: c.display_meta_text for c in comps} + assert "hacker" in metas + assert "green" in metas["hacker"].lower() + # descriptions differ across themes + assert len({m for m in metas.values() if m}) > 1 diff --git a/tests/test_theme.py b/tests/test_theme.py index c57628c..dade143 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -14,6 +14,7 @@ get_theme, is_known, list_themes, + theme_description, ) # Style keys every theme must resolve (mirrors `_make_theme`). @@ -65,3 +66,17 @@ def test_unknown_theme_falls_back_to_default() -> None: assert get_theme("nope") is THEMES[DEFAULT_THEME_NAME] assert get_palette(None) is get_palette(DEFAULT_THEME_NAME) assert not is_known("nope") + + +def test_every_theme_has_a_description() -> None: + seen = set() + for name in list_themes(): + desc = theme_description(name) + assert desc, f"theme {name!r} has no description" + seen.add(desc) + # descriptions are distinct (not the same generic line for all) + assert len(seen) == len(list_themes()) + + +def test_theme_description_hacker() -> None: + assert "green" in theme_description("hacker").lower()