From cde81bd15f3a6ebfebae6494fcb9f37ab627b50a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 8 Jun 2026 14:28:49 -0400 Subject: [PATCH 1/2] feat(tui): new theme + Catppuccin syntax highlighter Refresh the TUI theme and adopt a Catppuccin code-syntax highlighter: - Accent reharmonized to the brand periwinkle/indigo (#B3B9F4 dark / #0B114E light); border_accent and the selection tint follow the accent family (the selection bg is now sourced from a single constant feeding both the token and the prompt-toolkit styles). - Code blocks use Catppuccin Mocha (dark UI) / Latte (light UI), adaptive, hand-built as Pygments styles and rendered foreground-only on the calm code-block panel (transparent bg). 'pythinker-ansi' and stock Pygments styles remain opt-ins; the config default is now 'catppuccin-adaptive'. - Markdown uses terminal-native ANSI for inline code and links (cyan), blockquotes (green) and ordered-list markers (bright blue), mode-independent. Also fixes the vendored renderer that stripped blockquote colour and left list markers unstyled. - User-sent messages render on a neutral grey block (#333333 / #E0E0E0), removing the prior blue tint. Shimmer ramp constants now derive from the activity tokens and the unused tool_success_bg token is removed. --- src/pythinker_code/config.py | 11 +- src/pythinker_code/ui/shell/__init__.py | 7 +- .../ui/shell/components/markdown.py | 17 +- src/pythinker_code/ui/shell/motion.py | 12 +- src/pythinker_code/ui/theme.py | 65 ++++---- src/pythinker_code/utils/rich/markdown.py | 12 +- src/pythinker_code/utils/rich/syntax.py | 155 +++++++++++++++++- tests/core/test_config.py | 2 +- tests/ui_and_conv/test_code_theme_opt_in.py | 41 ++++- tests/ui_and_conv/test_md_color_contract.py | 31 +++- tests/ui_and_conv/test_shell_design_system.py | 4 +- tests/ui_and_conv/test_shell_panel.py | 5 +- tests/ui_and_conv/test_tui_theme_tokens.py | 80 +++++++-- 13 files changed, 356 insertions(+), 86 deletions(-) diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 5c3a191e..417b960f 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -602,13 +602,14 @@ class TUIConfig(BaseModel): description="Show a compact recap line after completed interactive shell turns.", ) code_theme: str = Field( - default="pythinker-ansi", + default="catppuccin-adaptive", description=( "Syntax-highlighting theme for assistant code blocks. Default " - "'pythinker-ansi' keeps the terminal-adaptive, transparent look. " - "Set to any Pygments style name (e.g. 'monokai', 'material', " - "'dracula', 'one-dark') to render code fences with that style on a " - "solid dark background block." + "'catppuccin-adaptive' uses Catppuccin Mocha (dark UI) / Latte " + "(light UI) on the calm transparent block. Use " + "'catppuccin-mocha'/'catppuccin-latte' to pin one, 'pythinker-ansi' " + "for the terminal-native ANSI look, or any Pygments style name (e.g. " + "'monokai', 'dracula') to render on that style's solid background." ), ) smooth_streaming: bool = Field( diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 00cc25f9..9f7288e4 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -1878,11 +1878,14 @@ def _cancel_background_tasks(self) -> None: self._background_tasks.clear() -# Palette transferred from the animated SVG (pythinker_animated.svg). +# Fixed brand palette transferred from the animated SVG (pythinker_animated.svg). +# These are the robot mark's identity colors and are intentionally +# theme-independent — do NOT wire them to TuiTokens (the logo must look the same +# in light/dark and must not shift with the accent). _LOGO_NAVY = "#213853" # outline / chassis (head + body frame, mouth, neck) _LOGO_FACE = "#F9F2F5" # face / chest interior (cream) _LOGO_CORAL = "#EE9983" # antenna ball, ears, accent bits -_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (light cyan) +_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (brand cyan) _LOGO = ( f" [{_LOGO_CORAL}]●[/]\n" diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index 5a30bdbd..d3749644 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -227,11 +227,18 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR # its padding share one uniform dark block (Aider-style); otherwise keep # the calm ``code_block_bg`` tint. if isinstance(self.theme, str): + # Stock opt-in style (e.g. monokai): paint the panel and code with the + # style's own background so they form one uniform solid block. panel_style = Syntax.get_theme(self.theme).get_background_style() + syntax_bg: str | None = None else: + # Default path (Catppuccin adaptive / ANSI sentinel): keep the calm + # code_block_bg tint on the panel and render the syntax transparently + # so only its foreground colors land (the "skip background" approach). panel_style = ( RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle() ) + syntax_bg = "default" syntax = Syntax( code_text, @@ -239,7 +246,7 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR theme=self.theme, word_wrap=True, padding=0, - background_color=None, + background_color=syntax_bg, ) highlighted = syntax.highlight(code_text) highlighted.rstrip() @@ -283,10 +290,10 @@ def _markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichS "markdown.hr": RichStyle(color=colors.code_block_border), "markdown.code_block": RichStyle(color=colors.inline_code), "markdown.code_block.border": RichStyle(color=colors.code_block_border, bold=True), - # Bullets/numbers are structural, not "important words" — keep them muted - # so the accent is reserved for headings and bold text. - "markdown.item.bullet": RichStyle(color=colors.quote, bold=True), - "markdown.item.number": RichStyle(color=colors.quote, bold=True), + # Ordered markers take the bright-blue accent; unordered bullets stay + # muted (structural, not "important words"). + "markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True), + "markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True), } diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index cd8c7796..ae3fc24c 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -55,14 +55,12 @@ def verb_spinner_style() -> Style: return Style(color=Color.parse(base)) -# Backwards-compatible dark-theme constants used by tests and older callers. -# These MUST stay in lockstep with the dark `activity_verb*` theme tokens so the -# shimmer renders the same warm ember ramp everywhere. -_SHIMMER_BASE = "#EE9983" -_SHIMMER_MID = "#F4B5A5" -_SHIMMER_HIGHLIGHT = "#FBD9CE" +# Dark-theme shimmer-ramp reference constants, sourced directly from the +# `activity_verb*` tokens so they can never drift out of lockstep with the theme. +_SHIMMER_BASE = get_tui_tokens("dark").activity_verb +_SHIMMER_MID = get_tui_tokens("dark").activity_verb_mid +_SHIMMER_HIGHLIGHT = get_tui_tokens("dark").activity_verb_highlight _SHIMMER_INTERVAL_S = 0.15 -_SPINNER_SILVER = "#B8C0CC" def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> Style: diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index eebe23b1..5b9ecbc3 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -141,6 +141,12 @@ def _task_browser_style_light() -> PTKStyle: # --------------------------------------------------------------------------- +# Selection-row background (accent-family tint). Single source of truth for the +# prompt-toolkit completion/dialog selection styles below AND the `selected_bg` +# TuiTokens field — keep them wired so the two never drift. +_SELECTED_BG_DARK = "#21243B" +_SELECTED_BG_LIGHT = "#E7E9F9" + _PROMPT_STYLE_DARK = { "bottom-toolbar": "noreverse", # Input area — minimal: no background bar, only the prompt glyph is @@ -159,10 +165,10 @@ def _task_browser_style_light() -> PTKStyle: "slash-completion-menu.command": "fg:#F4F4F5", "slash-completion-menu.command.match": "fg:#AFE3F1 bold", "slash-completion-menu.meta": "fg:#A3A3A3", - "slash-completion-menu.command.current": "bg:#243C54 fg:#F4F4F5 bold", - "slash-completion-menu.command.match.current": "bg:#243C54 fg:#AFE3F1 bold", - "slash-completion-menu.meta.current": "bg:#243C54 fg:#A3A3A3", - "slash-completion-menu.row.current": "bg:#243C54", + "slash-completion-menu.command.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold", + "slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_DARK} fg:#AFE3F1 bold", + "slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_DARK} fg:#A3A3A3", + "slash-completion-menu.row.current": f"bg:{_SELECTED_BG_DARK}", "file-completion-menu": "", "file-completion-menu.marker": "fg:#2B3A52", "file-completion-menu.marker.current": "fg:#AFE3F1 bold", @@ -175,7 +181,7 @@ def _task_browser_style_light() -> PTKStyle: "shell-dialog.title": "fg:#F4F4F5 bold", "shell-dialog.border": "fg:#2B3A52", "shell-dialog.option": "fg:#A3A3A3", - "shell-dialog.option.current": "bg:#243C54 fg:#F4F4F5 bold", + "shell-dialog.option.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold", "shell-footer.key": "fg:#AFE3F1 bold", "shell-footer.meta": "fg:#A3A3A3", "shell-footer.warning": "fg:#E6B450", @@ -196,10 +202,10 @@ def _task_browser_style_light() -> PTKStyle: "slash-completion-menu.command": "fg:#4b5563", "slash-completion-menu.command.match": "fg:#176B7E bold", "slash-completion-menu.meta": "fg:#666666", - "slash-completion-menu.command.current": "bg:#E6F2F6 fg:#213853 bold", - "slash-completion-menu.command.match.current": "bg:#E6F2F6 fg:#176B7E bold", - "slash-completion-menu.meta.current": "bg:#E6F2F6 fg:#666666", - "slash-completion-menu.row.current": "bg:#E6F2F6", + "slash-completion-menu.command.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold", + "slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_LIGHT} fg:#176B7E bold", + "slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_LIGHT} fg:#666666", + "slash-completion-menu.row.current": f"bg:{_SELECTED_BG_LIGHT}", "file-completion-menu": "", "file-completion-menu.marker": "fg:#8A93A0", "file-completion-menu.marker.current": "fg:#176B7E bold", @@ -212,7 +218,7 @@ def _task_browser_style_light() -> PTKStyle: "shell-dialog.title": "fg:#213853 bold", "shell-dialog.border": "fg:#C8BEC0", "shell-dialog.option": "fg:#666666", - "shell-dialog.option.current": "bg:#E6F2F6 fg:#213853 bold", + "shell-dialog.option.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold", "shell-footer.key": "fg:#176B7E bold", "shell-footer.meta": "fg:#666666", "shell-footer.warning": "fg:#9A6B18", @@ -279,6 +285,8 @@ class MarkdownColors: inline_code: str link: str quote: str + ordered_marker: str + unordered_marker: str table_border: str code_block_border: str code_block_bg: str @@ -287,18 +295,22 @@ class MarkdownColors: spinner_failed: str -# Markdown/report role mapping: prose-heavy output stays professional and -# low-chrome. Headings/strong text use primary text, emphasis/quotes use muted -# grey, code/links use blue, and status accents stay green/red. -# All values are derived from TuiTokens so there is a single source of truth. +# Markdown/report role mapping. Headings/strong use primary text, emphasis and +# unordered bullets use muted grey, status accents stay green/red — all derived +# from TuiTokens. The four enumerated elements (inline code, links, blockquotes, +# ordered-list markers) instead use terminal-native ANSI names so they adapt to +# the user's terminal palette in both light and dark modes (see the design spec +# 2026-06-08). def _build_markdown_colors(tokens: TuiTokens) -> MarkdownColors: return MarkdownColors( heading=tokens.tool_title, emphasis=tokens.muted, strong=tokens.tool_title, - inline_code=tokens.info, - link=tokens.info, - quote=tokens.muted, + inline_code="cyan", # terminal-native ANSI cyan + link="cyan", # cyan, rendered underlined + quote="green", # terminal-native ANSI green + ordered_marker="bright_blue", # ordered markers take the bright-blue accent + unordered_marker=tokens.muted, # unordered bullets stay muted table_border=tokens.border_muted, code_block_border=tokens.border_muted, code_block_bg=tokens.code_block_bg, @@ -453,7 +465,6 @@ class TuiTokens: custom_message_text: str custom_message_label: str tool_pending_bg: str - tool_success_bg: str tool_error_bg: str tool_title: str tool_output: str @@ -471,9 +482,9 @@ class TuiTokens: _TUI_TOKENS_DARK = TuiTokens( - accent="#5EA7E8", + accent="#B3B9F4", border="#3A506D", - border_accent="#AFE3F1", + border_accent="#7C88DE", border_muted="#2B3A52", info="#AFE3F1", success="#7BC97F", @@ -488,14 +499,13 @@ class TuiTokens: activity_verb_mid="#F4B5A5", activity_verb_highlight="#FBD9CE", activity_spinner="#B8C0CC", - selected_bg="#243C54", - user_message_bg="#1B2738", + selected_bg=_SELECTED_BG_DARK, + user_message_bg="#333333", user_message_text="", custom_message_bg="#16242E", custom_message_text="", custom_message_label="#AFE3F1", tool_pending_bg="#1B2230", - tool_success_bg="#16271C", tool_error_bg="#2E1D24", tool_title="#F4F4F5", tool_output="#A3A3A3", @@ -508,9 +518,9 @@ class TuiTokens: _TUI_TOKENS_LIGHT = TuiTokens( - accent="#256EA8", + accent="#0B114E", border="#495F7C", - border_accent="#176B7E", + border_accent="#3B469B", border_muted="#C8BEC0", info="#176B7E", success="#2C7A39", @@ -525,14 +535,13 @@ class TuiTokens: activity_verb_mid="#B0573C", activity_verb_highlight="#8F3A26", activity_spinner="#6B7280", - selected_bg="#E6F2F6", - user_message_bg="#F0E4E4", + selected_bg=_SELECTED_BG_LIGHT, + user_message_bg="#E0E0E0", user_message_text="", custom_message_bg="#E6F2F6", custom_message_text="", custom_message_label="#176B7E", tool_pending_bg="#EFE7E8", - tool_success_bg="#E4F0E6", tool_error_bg="#F6E3E3", tool_title="#213853", tool_output="#666666", diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index e804fab0..e06edad0 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -276,7 +276,9 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: render_options = options.update(width=options.max_width - 4) - style = self.style.without_color + # Keep the blockquote's own colour (blockquotes render green); the + # ``▌`` bar and quoted text share it. + style = self.style lines = console.render_lines(self.elements, render_options, style=style) new_line = Segment("\n") padding = Segment("▌ ", style) @@ -478,7 +480,8 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult: indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len - bullet = Segment("• ") + bullet_style = console.get_style("markdown.item.bullet", default="none") + bullet = Segment("• ", bullet_style) new_line = Segment("\n") bullet_width = cell_len(bullet.text) child_width = max(1, options.max_width - indent_padding_len - bullet_width) @@ -513,7 +516,8 @@ def render_number( indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len numeral_text = f"{number}. " - numeral = Segment(numeral_text) + number_style = console.get_style("markdown.item.number", default="none") + numeral = Segment(numeral_text, number_style) numeral_width = cell_len(numeral_text) child_width = max(1, options.max_width - indent_padding_len - numeral_width) lines = console.render_lines( @@ -637,8 +641,6 @@ def enter_style(self, style_name: str | Style) -> Style: style = self.console.get_style(style_name, default=fallback) style = fallback + style style = style.copy() - if isinstance(style_name, str) and style_name == "markdown.block_quote": - style = style.without_color if ( isinstance(style_name, str) and style_name in {"markdown.code", "markdown.code_block"} diff --git a/src/pythinker_code/utils/rich/syntax.py b/src/pythinker_code/utils/rich/syntax.py index 413d8c3b..af8e301e 100644 --- a/src/pythinker_code/utils/rich/syntax.py +++ b/src/pythinker_code/utils/rich/syntax.py @@ -2,8 +2,10 @@ from typing import Any +from pygments.style import Style as PygmentsStyle from pygments.token import ( Comment, + Error, Generic, Keyword, Name, @@ -11,6 +13,7 @@ Operator, Punctuation, String, + Whitespace, ) from pygments.token import ( Literal as PygmentsLiteral, @@ -22,7 +25,7 @@ Token as PygmentsToken, ) from rich.style import Style -from rich.syntax import ANSISyntaxTheme, Syntax, SyntaxTheme +from rich.syntax import ANSISyntaxTheme, PygmentsSyntaxTheme, Syntax, SyntaxTheme PYTHINKER_ANSI_THEME_NAME = "pythinker-ansi" PYTHINKER_ANSI_THEME = ANSISyntaxTheme( @@ -78,28 +81,166 @@ ) +# --------------------------------------------------------------------------- +# Catppuccin Mocha / Latte — hand-built as Pygments styles from the official +# palette + the canonical Catppuccin syntax mapping, with NO italic/underline +# and minimal bold (foreground + bold only). Rendered with a transparent +# background so they respect the terminal/panel bg. +# --------------------------------------------------------------------------- + +CATPPUCCIN_ADAPTIVE_THEME_NAME = "catppuccin-adaptive" +CATPPUCCIN_MOCHA_THEME_NAME = "catppuccin-mocha" +CATPPUCCIN_LATTE_THEME_NAME = "catppuccin-latte" + +# Official palettes (catppuccin.com/palette). +_CATPPUCCIN_MOCHA = { + "base": "#1e1e2e", + "text": "#cdd6f4", + "overlay0": "#6c7086", + "overlay2": "#9399b2", + "mauve": "#cba6f7", + "red": "#f38ba8", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "blue": "#89b4fa", + "pink": "#f5c2e7", +} +_CATPPUCCIN_LATTE = { + "base": "#eff1f5", + "text": "#4c4f69", + "overlay0": "#9ca0b0", + "overlay2": "#7c7f93", + "mauve": "#8839ef", + "red": "#d20f39", + "peach": "#fe640b", + "yellow": "#df8e1d", + "green": "#40a02b", + "teal": "#179299", + "sky": "#04a5e5", + "blue": "#1e66f5", + "pink": "#ea76cb", +} + + +def _catppuccin_styles(p: dict[str, str]) -> dict[Any, str]: + """Canonical Catppuccin token → color mapping (no italic/underline).""" + return { + PygmentsToken: p["text"], + PygmentsText: p["text"], + Whitespace: p["text"], + Comment: p["overlay0"], + Comment.Preproc: p["pink"], + Keyword: p["mauve"], + Keyword.Constant: p["peach"], + Keyword.Declaration: p["mauve"], + Keyword.Namespace: p["mauve"], + Keyword.Pseudo: p["mauve"], + Keyword.Reserved: p["mauve"], + Keyword.Type: p["yellow"], + Operator: p["sky"], + Operator.Word: p["mauve"], + Punctuation: p["overlay2"], + Name: p["text"], + Name.Attribute: p["blue"], + Name.Builtin: p["red"], + Name.Builtin.Pseudo: p["red"], + Name.Class: p["yellow"], + Name.Constant: p["peach"], + Name.Decorator: p["blue"], + Name.Entity: p["pink"], + Name.Exception: p["yellow"], + Name.Function: p["blue"], + Name.Function.Magic: p["sky"], + Name.Label: p["peach"], + Name.Namespace: p["yellow"], + Name.Property: p["teal"], + Name.Tag: p["mauve"], + Name.Variable: p["text"], + Name.Variable.Magic: p["red"], + Number: p["peach"], + PygmentsLiteral: p["peach"], + String: p["green"], + String.Doc: p["overlay0"], + String.Escape: p["pink"], + String.Interpol: p["pink"], + String.Regex: p["pink"], + String.Symbol: p["red"], + Generic.Deleted: p["red"], + Generic.Inserted: p["green"], + Generic.Heading: f"bold {p['blue']}", + Generic.Subheading: f"bold {p['blue']}", + Generic.Strong: "bold", + Generic.Emph: p["text"], # italic intentionally omitted + Generic.Error: p["red"], + Generic.Traceback: p["red"], + Error: p["red"], + } + + +class CatppuccinMochaStyle(PygmentsStyle): + name = "catppuccin-mocha" + background_color = _CATPPUCCIN_MOCHA["base"] + styles = _catppuccin_styles(_CATPPUCCIN_MOCHA) + + +class CatppuccinLatteStyle(PygmentsStyle): + name = "catppuccin-latte" + background_color = _CATPPUCCIN_LATTE["base"] + styles = _catppuccin_styles(_CATPPUCCIN_LATTE) + + +CATPPUCCIN_MOCHA_THEME = PygmentsSyntaxTheme(CatppuccinMochaStyle) +CATPPUCCIN_LATTE_THEME = PygmentsSyntaxTheme(CatppuccinLatteStyle) + + def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme: - if isinstance(theme, str) and theme.lower() == PYTHINKER_ANSI_THEME_NAME: - return PYTHINKER_ANSI_THEME + if isinstance(theme, str): + name = theme.lower() + if name == PYTHINKER_ANSI_THEME_NAME: + return PYTHINKER_ANSI_THEME + if name == CATPPUCCIN_ADAPTIVE_THEME_NAME: + # Follow the active UI theme: Latte on light terminals, Mocha + # otherwise. Imported lazily to avoid a circular import at module + # load (ui.theme is a higher layer). + from pythinker_code.ui.theme import get_active_theme + + if get_active_theme() == "light": + return CATPPUCCIN_LATTE_THEME + return CATPPUCCIN_MOCHA_THEME + if name == CATPPUCCIN_MOCHA_THEME_NAME: + return CATPPUCCIN_MOCHA_THEME + if name == CATPPUCCIN_LATTE_THEME_NAME: + return CATPPUCCIN_LATTE_THEME return theme def available_code_themes() -> list[str]: - """Accepted ``code_theme`` values: the ANSI sentinel plus every stock Pygments style. + """Accepted ``code_theme`` values: the Catppuccin + ANSI sentinels plus every + stock Pygments style. Imported lazily so the (modest) Pygments style enumeration cost is only paid when a config value is validated, not on every ``syntax`` import. """ from pygments.styles import get_all_styles - return [PYTHINKER_ANSI_THEME_NAME, *sorted(get_all_styles())] + return [ + CATPPUCCIN_ADAPTIVE_THEME_NAME, + CATPPUCCIN_MOCHA_THEME_NAME, + CATPPUCCIN_LATTE_THEME_NAME, + PYTHINKER_ANSI_THEME_NAME, + *sorted(get_all_styles()), + ] # Process-wide default code-fence theme, resolved once at shell startup from # ``config.tui.code_theme``. Mirrors ``ui.theme`` set_active_theme/get_active_theme # so renderers pick up the configured theme without threading config through -# every call site. ``PYTHINKER_ANSI_THEME_NAME`` keeps today's transparent look. -_active_code_theme: str = PYTHINKER_ANSI_THEME_NAME +# every call site. ``CATPPUCCIN_ADAPTIVE_THEME_NAME`` is the default and follows +# the active light/dark UI theme (Mocha on dark, Latte on light). +_active_code_theme: str = CATPPUCCIN_ADAPTIVE_THEME_NAME def set_active_code_theme(theme: str) -> None: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 5a54753a..4a9e149d 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -98,7 +98,7 @@ def test_default_config_dump(): "style": "card", "prompt_history_enabled": True, "turn_recaps": True, - "code_theme": "pythinker-ansi", + "code_theme": "catppuccin-adaptive", "smooth_streaming": True, }, } diff --git a/tests/ui_and_conv/test_code_theme_opt_in.py b/tests/ui_and_conv/test_code_theme_opt_in.py index e4d146e5..920d5a2b 100644 --- a/tests/ui_and_conv/test_code_theme_opt_in.py +++ b/tests/ui_and_conv/test_code_theme_opt_in.py @@ -15,7 +15,11 @@ from pythinker_code.config import TUIConfig from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown +from pythinker_code.ui.theme import get_active_theme, set_active_theme from pythinker_code.utils.rich.syntax import ( + CATPPUCCIN_ADAPTIVE_THEME_NAME, + CATPPUCCIN_LATTE_THEME, + CATPPUCCIN_MOCHA_THEME, PYTHINKER_ANSI_THEME, PYTHINKER_ANSI_THEME_NAME, PythinkerSyntax, @@ -85,13 +89,46 @@ def test_resolve_code_theme_maps_only_the_sentinel() -> None: assert resolve_code_theme("monokai") == "monokai" -def test_tui_config_accepts_sentinel_and_stock_styles() -> None: - assert TUIConfig().code_theme == PYTHINKER_ANSI_THEME_NAME +def test_tui_config_defaults_to_catppuccin_adaptive() -> None: + # New default: Catppuccin Mocha/Latte adaptive. + assert TUIConfig().code_theme == CATPPUCCIN_ADAPTIVE_THEME_NAME assert TUIConfig(code_theme="monokai").code_theme == "monokai" + # The ANSI sentinel and stock styles remain valid opt-ins. + assert TUIConfig(code_theme=PYTHINKER_ANSI_THEME_NAME).code_theme == PYTHINKER_ANSI_THEME_NAME # Case-insensitive convenience: a known style name is normalized to lower. assert TUIConfig(code_theme="Monokai").code_theme == "monokai" +def test_catppuccin_adaptive_default_follows_active_ui_theme() -> None: + """The adaptive default resolves to Mocha in dark mode and Latte in light.""" + saved = get_active_theme() + try: + set_active_theme("dark") + assert resolve_code_theme(CATPPUCCIN_ADAPTIVE_THEME_NAME) is CATPPUCCIN_MOCHA_THEME + set_active_theme("light") + assert resolve_code_theme(CATPPUCCIN_ADAPTIVE_THEME_NAME) is CATPPUCCIN_LATTE_THEME + finally: + set_active_theme(saved) + + +def test_catppuccin_renders_foreground_but_no_solid_background() -> None: + """Catppuccin applies its palette foreground while the panel keeps the calm + code_block_bg (transparent syntax bg) — the 'skip background' approach.""" + saved_theme = get_active_theme() + try: + set_active_theme("dark") + set_active_code_theme(CATPPUCCIN_ADAPTIVE_THEME_NAME) + output = _render_ansi(_FENCE) + # Mocha mauve (#cba6f7) foreground on the `import` keyword. + assert "38;2;203;166;247" in output + # Mocha base (#1e1e2e) must NOT be painted as a syntax background. + assert "48;2;30;30;46" not in output + # italic (SGR 3) is intentionally omitted; the Catppuccin styles set none. + assert "\x1b[3m" not in output + finally: + set_active_theme(saved_theme) + + def test_tui_config_rejects_unknown_code_theme() -> None: with pytest.raises(ValueError, match="Unknown code_theme"): TUIConfig(code_theme="definitely-not-a-real-style") diff --git a/tests/ui_and_conv/test_md_color_contract.py b/tests/ui_and_conv/test_md_color_contract.py index 599130fe..3855ea21 100644 --- a/tests/ui_and_conv/test_md_color_contract.py +++ b/tests/ui_and_conv/test_md_color_contract.py @@ -7,16 +7,37 @@ from __future__ import annotations +import re + +from rich.console import Console +from rich.style import Style as RichStyle +from rich.text import Text + from pythinker_code.ui.shell.components.markdown import pythinker_markdown from pythinker_code.ui.theme import get_markdown_colors from tests.ui_and_conv._md_contract_helpers import render_ansi -def _sgr_fg(hexcolor: str) -> str: - """Build the truecolor foreground SGR fragment for a #rrggbb color.""" - h = hexcolor.lstrip("#") - r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) - return f"38;2;{r};{g};{b}" +def _sgr_fg(color: str) -> str: + """Return the exact SGR escape Rich emits for ``color`` as a foreground. + + Works for both ``#rrggbb`` (truecolor ``38;2;..``) and ANSI names such as + ``"cyan"`` (``36``) — markdown code/links now use terminal ANSI names. + """ + console = Console(force_terminal=True, color_system="truecolor", width=10) + with console.capture() as cap: + console.print(Text("X", style=RichStyle(color=color)), end="") + match = re.search(r"\x1b\[[0-9;]*m", cap.get()) + return match.group(0) if match else "" + + +def test_blockquote_and_ordered_markers_render_ansi_colors(): + """Blockquotes render green and ordered-list markers render bright_blue in + the actual output (not just at the palette level).""" + md = "> quoted line\n\n1. first item\n2. second item\n" + out = render_ansi(pythinker_markdown(md), width=40) + assert re.search(r"\x1b\[(?:\d+;)*32m", out), "blockquote text is not green" + assert re.search(r"\x1b\[(?:\d+;)*94m", out), "ordered marker is not bright_blue" def test_code_block_border_does_not_use_inline_code_color(): diff --git a/tests/ui_and_conv/test_shell_design_system.py b/tests/ui_and_conv/test_shell_design_system.py index f099793a..006833d6 100644 --- a/tests/ui_and_conv/test_shell_design_system.py +++ b/tests/ui_and_conv/test_shell_design_system.py @@ -81,10 +81,10 @@ def test_shell_style_resolves_brand_tokens_and_switches_theme(): from pythinker_code.ui.theme import set_active_theme set_active_theme("dark") - assert _color_hex(shell_style(ShellTone.ACCENT)) == "#5ea7e8" + assert _color_hex(shell_style(ShellTone.ACCENT)) == "#b3b9f4" assert _color_hex(shell_style(ShellTone.SUCCESS)) == "#7bc97f" set_active_theme("light") - assert _color_hex(shell_style(ShellTone.ACCENT)) == "#256ea8" + assert _color_hex(shell_style(ShellTone.ACCENT)) == "#0b114e" set_active_theme("dark") diff --git a/tests/ui_and_conv/test_shell_panel.py b/tests/ui_and_conv/test_shell_panel.py index 9cdebfa2..869359df 100644 --- a/tests/ui_and_conv/test_shell_panel.py +++ b/tests/ui_and_conv/test_shell_panel.py @@ -12,7 +12,8 @@ def test_brand_panel_is_rounded_and_uses_border_token(): assert "#3a506d" in str(p.border_style).lower() -def test_brand_panel_active_uses_info_border(): +def test_brand_panel_active_uses_accent_border(): set_active_theme("dark") p = brand_panel("hi", active=True) - assert "#afe3f1" in str(p.border_style).lower() + # Active panels use the accent-family border_accent token (periwinkle). + assert "#7c88de" in str(p.border_style).lower() diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index e0cddadb..c8955808 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -32,9 +32,10 @@ def _restore_active_theme(): def test_dark_tokens_have_brand_values(): set_active_theme("dark") t = get_tui_tokens() - assert t.accent == "#5EA7E8" # medium blue for files/extensions + assert t.accent == "#B3B9F4" # periwinkle brand accent (≈ Catppuccin Mocha lavender) + assert t.border_accent == "#7C88DE" # accent-family chrome (active borders) assert t.border == "#3A506D" # slate - assert t.info == "#AFE3F1" # cyan + assert t.info == "#AFE3F1" # cyan (unchanged; markdown code/links use ANSI cyan) assert t.success == "#7BC97F" assert t.error == "#EF5E62" assert t.thinking_text == "#C0C0C0" # lighter neutral grey, not purple-tinted muted @@ -51,8 +52,9 @@ def test_dark_tokens_have_brand_values(): def test_light_tokens_have_brand_values(): set_active_theme("light") t = get_tui_tokens() - assert t.accent == "#256EA8" # text-safe medium blue - assert t.info == "#176B7E" # text-safe cyan + assert t.accent == "#0B114E" # deep indigo brand accent (light mode) + assert t.border_accent == "#3B469B" # accent-family chrome (active borders) + assert t.info == "#176B7E" # text-safe cyan (unchanged) assert t.text == "#213853" # navy text assert t.error == "#C0392B" assert t.thinking_text == "#7A7A7A" # lighter neutral grey, not blue/purple muted @@ -77,6 +79,35 @@ def test_text_token_is_empty_string_for_terminal_default(): assert get_tui_tokens("dark").text == "" +def test_selected_bg_reharmonized_and_drives_prompt_selection(): + """selected_bg joins the accent (periwinkle/indigo) family and is the single + source for the completion/dialog selection rows (no parallel literals).""" + from pythinker_code.ui.theme import _PROMPT_STYLE_DARK, _PROMPT_STYLE_LIGHT + + assert get_tui_tokens("dark").selected_bg == "#21243B" + assert get_tui_tokens("light").selected_bg == "#E7E9F9" + assert _PROMPT_STYLE_DARK["slash-completion-menu.row.current"] == ( + f"bg:{get_tui_tokens('dark').selected_bg}" + ) + assert _PROMPT_STYLE_LIGHT["slash-completion-menu.row.current"] == ( + f"bg:{get_tui_tokens('light').selected_bg}" + ) + + +def test_user_message_bg_is_neutral_grey_not_tinted(): + """User-sent messages use a neutral grey block (not the old blue tint). + + The block must stay visible (same lightness as before) but carry no hue — + R, G and B within a tight tolerance — so it reads as 'grey, not blur'. + """ + expected = {"dark": "#333333", "light": "#E0E0E0"} + for mode, hexval in expected.items(): + token = get_tui_tokens(mode).user_message_bg + assert token == hexval, mode + r, g, b = int(token[1:3], 16), int(token[3:5], 16), int(token[5:7], 16) + assert abs(r - g) <= 4 and abs(g - b) <= 4, f"{mode} not neutral grey: {token}" + + def test_tokens_dataclass_is_frozen(): t = get_tui_tokens("dark") with pytest.raises(dataclasses.FrozenInstanceError): @@ -122,9 +153,9 @@ def test_dark_markdown_uses_professional_report_roles(): assert colors.heading == "#F4F4F5" # primary white, not coral/orange assert colors.strong == "#F4F4F5" assert colors.emphasis == "#A3A3A3" # neutral grey - assert colors.inline_code == "#AFE3F1" # blue/cyan accent - assert colors.link == "#AFE3F1" - assert colors.spinner_active == "#AFE3F1" + assert colors.inline_code == "cyan" # terminal-native ANSI + assert colors.link == "cyan" + assert colors.spinner_active == "#AFE3F1" # spinners still use the info token assert colors.spinner_done == "#7BC97F" assert colors.spinner_failed == "#EF5E62" assert markdown_rich_style("link", theme="dark").color is not None @@ -135,8 +166,20 @@ def test_light_markdown_uses_professional_report_roles(): assert colors.heading == "#213853" assert colors.strong == "#213853" assert colors.emphasis == "#666666" - assert colors.inline_code == "#176B7E" - assert colors.spinner_active == "#176B7E" + assert colors.inline_code == "cyan" # terminal-native ANSI + assert colors.spinner_active == "#176B7E" # spinners still use the info token + + +def test_markdown_ansi_styles_resolve_to_terminal_colors(): + """The four enumerated elements resolve to ANSI terminal colors in both + modes (so they adapt to the user's terminal palette).""" + for mode in ("dark", "light"): + assert markdown_rich_style("inline_code", theme=mode).color.name == "cyan" + assert markdown_rich_style("link", theme=mode).color.name == "cyan" + assert markdown_rich_style("quote", theme=mode).color.name == "green" + assert markdown_rich_style("ordered_marker", theme=mode).color.name == "bright_blue" + # Unordered bullets stay muted (a hex), not an ANSI accent. + assert markdown_rich_style("unordered_marker", theme=mode).color.name != "green" def test_info_token_exists_and_is_cyan(): @@ -191,9 +234,13 @@ def test_markdown_colors_derived_from_tokens_dark(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - assert c.inline_code == t.info - assert c.link == t.info - assert c.quote == t.muted + # The four enumerated markdown elements use terminal-native ANSI + # (mode-independent), NOT theme tokens — see the design spec 2026-06-08. + assert c.inline_code == "cyan" + assert c.link == "cyan" + assert c.quote == "green" + assert c.ordered_marker == "bright_blue" + assert c.unordered_marker == t.muted # unordered bullets stay muted assert c.table_border == t.border_muted assert c.code_block_border == t.border_muted assert c.code_block_bg == t.code_block_bg @@ -208,9 +255,12 @@ def test_markdown_colors_derived_from_tokens_light(): assert c.heading == t.tool_title assert c.strong == t.tool_title assert c.emphasis == t.muted - assert c.inline_code == t.info - assert c.link == t.info - assert c.quote == t.muted + # terminal-native ANSI is mode-independent (same cyan/green/bright_blue both modes) + assert c.inline_code == "cyan" + assert c.link == "cyan" + assert c.quote == "green" + assert c.ordered_marker == "bright_blue" + assert c.unordered_marker == t.muted assert c.table_border == t.border_muted assert c.code_block_border == t.border_muted assert c.code_block_bg == t.code_block_bg From 7fe3421d8540337b1bfeb06f6170c2f2b9c2be68 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 8 Jun 2026 15:17:15 -0400 Subject: [PATCH 2/2] fix(tui): satisfy pyright in theme-token tests + add changelog entry The CI `check` job failed pyright on tests/ui_and_conv/test_tui_theme_tokens.py: type the `expected` dict keys as ThemeName so the loop var is accepted by get_tui_tokens, and route markdown_rich_style(...).color.name through a _color_name helper that asserts the optional Color is present. Also add the missing `## Unreleased` CHANGELOG bullet for the new TUI theme + Catppuccin highlighter, fixing the `changelog` job. --- CHANGELOG.md | 1 + tests/ui_and_conv/test_tui_theme_tokens.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e437cab0..b621a9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Refreshed TUI theme and Catppuccin syntax highlighting.** The interface adopts a brand periwinkle/indigo accent (`#B3B9F4` dark / `#0B114E` light) with a reharmonized selection tint, and code blocks now highlight with Catppuccin Mocha (dark) / Latte (light), adaptive to the active theme — implemented as foreground-only Pygments styles with no new dependency. Markdown inline code and links render terminal-native cyan, blockquotes green, and ordered-list markers bright blue (so they adapt per terminal), and user messages sit on a neutral grey block instead of the prior blue tint. - **Homebrew updater no longer no-ops or false-reports success.** `pythinker update` on a Homebrew install now runs `brew update` to refresh the tap before `brew upgrade`, so a stale local tap clone can't pin the old formula and silently no-op ("0.37.0 already installed"). After upgrading it re-checks the installed version via `brew list --versions` and reports a clear failure instead of "Updated successfully!" when the version did not actually advance. ## 0.38.0 (2026-06-08) diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index c8955808..e1b3b056 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -9,6 +9,7 @@ from pythinker_code.ui.theme import ( TUI_TOKEN_NAMES, + ThemeName, TuiTokens, get_active_theme, get_markdown_colors, @@ -19,6 +20,13 @@ ) +def _color_name(style: RichStyle) -> str: + """Return the resolved color name, asserting the style actually set one.""" + color = style.color + assert color is not None + return color.name + + @pytest.fixture(autouse=True) def _restore_active_theme(): """Snapshot/restore the global active theme so tests don't bleed.""" @@ -100,7 +108,7 @@ def test_user_message_bg_is_neutral_grey_not_tinted(): The block must stay visible (same lightness as before) but carry no hue — R, G and B within a tight tolerance — so it reads as 'grey, not blur'. """ - expected = {"dark": "#333333", "light": "#E0E0E0"} + expected: dict[ThemeName, str] = {"dark": "#333333", "light": "#E0E0E0"} for mode, hexval in expected.items(): token = get_tui_tokens(mode).user_message_bg assert token == hexval, mode @@ -174,12 +182,12 @@ def test_markdown_ansi_styles_resolve_to_terminal_colors(): """The four enumerated elements resolve to ANSI terminal colors in both modes (so they adapt to the user's terminal palette).""" for mode in ("dark", "light"): - assert markdown_rich_style("inline_code", theme=mode).color.name == "cyan" - assert markdown_rich_style("link", theme=mode).color.name == "cyan" - assert markdown_rich_style("quote", theme=mode).color.name == "green" - assert markdown_rich_style("ordered_marker", theme=mode).color.name == "bright_blue" + assert _color_name(markdown_rich_style("inline_code", theme=mode)) == "cyan" + assert _color_name(markdown_rich_style("link", theme=mode)) == "cyan" + assert _color_name(markdown_rich_style("quote", theme=mode)) == "green" + assert _color_name(markdown_rich_style("ordered_marker", theme=mode)) == "bright_blue" # Unordered bullets stay muted (a hex), not an ANSI accent. - assert markdown_rich_style("unordered_marker", theme=mode).color.name != "green" + assert _color_name(markdown_rich_style("unordered_marker", theme=mode)) != "green" def test_info_token_exists_and_is_cyan():