Skip to content

Commit 1b840f4

Browse files
authored
feat(tui): new theme + Catppuccin syntax highlighter (#88)
* 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. * 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.
1 parent f811f87 commit 1b840f4

14 files changed

Lines changed: 365 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **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.
1819
- **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.
1920

2021
## 0.38.0 (2026-06-08)

src/pythinker_code/config.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -602,13 +602,14 @@ class TUIConfig(BaseModel):
602602
description="Show a compact recap line after completed interactive shell turns.",
603603
)
604604
code_theme: str = Field(
605-
default="pythinker-ansi",
605+
default="catppuccin-adaptive",
606606
description=(
607607
"Syntax-highlighting theme for assistant code blocks. Default "
608-
"'pythinker-ansi' keeps the terminal-adaptive, transparent look. "
609-
"Set to any Pygments style name (e.g. 'monokai', 'material', "
610-
"'dracula', 'one-dark') to render code fences with that style on a "
611-
"solid dark background block."
608+
"'catppuccin-adaptive' uses Catppuccin Mocha (dark UI) / Latte "
609+
"(light UI) on the calm transparent block. Use "
610+
"'catppuccin-mocha'/'catppuccin-latte' to pin one, 'pythinker-ansi' "
611+
"for the terminal-native ANSI look, or any Pygments style name (e.g. "
612+
"'monokai', 'dracula') to render on that style's solid background."
612613
),
613614
)
614615
smooth_streaming: bool = Field(

src/pythinker_code/ui/shell/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,11 +1878,14 @@ def _cancel_background_tasks(self) -> None:
18781878
self._background_tasks.clear()
18791879

18801880

1881-
# Palette transferred from the animated SVG (pythinker_animated.svg).
1881+
# Fixed brand palette transferred from the animated SVG (pythinker_animated.svg).
1882+
# These are the robot mark's identity colors and are intentionally
1883+
# theme-independent — do NOT wire them to TuiTokens (the logo must look the same
1884+
# in light/dark and must not shift with the accent).
18821885
_LOGO_NAVY = "#213853" # outline / chassis (head + body frame, mouth, neck)
18831886
_LOGO_FACE = "#F9F2F5" # face / chest interior (cream)
18841887
_LOGO_CORAL = "#EE9983" # antenna ball, ears, accent bits
1885-
_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (light cyan)
1888+
_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (brand cyan)
18861889

18871890
_LOGO = (
18881891
f" [{_LOGO_CORAL}]●[/]\n"

src/pythinker_code/ui/shell/components/markdown.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,19 +227,26 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR
227227
# its padding share one uniform dark block (Aider-style); otherwise keep
228228
# the calm ``code_block_bg`` tint.
229229
if isinstance(self.theme, str):
230+
# Stock opt-in style (e.g. monokai): paint the panel and code with the
231+
# style's own background so they form one uniform solid block.
230232
panel_style = Syntax.get_theme(self.theme).get_background_style()
233+
syntax_bg: str | None = None
231234
else:
235+
# Default path (Catppuccin adaptive / ANSI sentinel): keep the calm
236+
# code_block_bg tint on the panel and render the syntax transparently
237+
# so only its foreground colors land (the "skip background" approach).
232238
panel_style = (
233239
RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle()
234240
)
241+
syntax_bg = "default"
235242

236243
syntax = Syntax(
237244
code_text,
238245
self.lexer_name,
239246
theme=self.theme,
240247
word_wrap=True,
241248
padding=0,
242-
background_color=None,
249+
background_color=syntax_bg,
243250
)
244251
highlighted = syntax.highlight(code_text)
245252
highlighted.rstrip()
@@ -283,10 +290,10 @@ def _markdown_style_overrides(theme: ThemeName | None = None) -> dict[str, RichS
283290
"markdown.hr": RichStyle(color=colors.code_block_border),
284291
"markdown.code_block": RichStyle(color=colors.inline_code),
285292
"markdown.code_block.border": RichStyle(color=colors.code_block_border, bold=True),
286-
# Bullets/numbers are structural, not "important words" — keep them muted
287-
# so the accent is reserved for headings and bold text.
288-
"markdown.item.bullet": RichStyle(color=colors.quote, bold=True),
289-
"markdown.item.number": RichStyle(color=colors.quote, bold=True),
293+
# Ordered markers take the bright-blue accent; unordered bullets stay
294+
# muted (structural, not "important words").
295+
"markdown.item.bullet": RichStyle(color=colors.unordered_marker, bold=True),
296+
"markdown.item.number": RichStyle(color=colors.ordered_marker, bold=True),
290297
}
291298

292299

src/pythinker_code/ui/shell/motion.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,12 @@ def verb_spinner_style() -> Style:
5555
return Style(color=Color.parse(base))
5656

5757

58-
# Backwards-compatible dark-theme constants used by tests and older callers.
59-
# These MUST stay in lockstep with the dark `activity_verb*` theme tokens so the
60-
# shimmer renders the same warm ember ramp everywhere.
61-
_SHIMMER_BASE = "#EE9983"
62-
_SHIMMER_MID = "#F4B5A5"
63-
_SHIMMER_HIGHLIGHT = "#FBD9CE"
58+
# Dark-theme shimmer-ramp reference constants, sourced directly from the
59+
# `activity_verb*` tokens so they can never drift out of lockstep with the theme.
60+
_SHIMMER_BASE = get_tui_tokens("dark").activity_verb
61+
_SHIMMER_MID = get_tui_tokens("dark").activity_verb_mid
62+
_SHIMMER_HIGHLIGHT = get_tui_tokens("dark").activity_verb_highlight
6463
_SHIMMER_INTERVAL_S = 0.15
65-
_SPINNER_SILVER = "#B8C0CC"
6664

6765

6866
def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> Style:

src/pythinker_code/ui/theme.py

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ def _task_browser_style_light() -> PTKStyle:
141141
# ---------------------------------------------------------------------------
142142

143143

144+
# Selection-row background (accent-family tint). Single source of truth for the
145+
# prompt-toolkit completion/dialog selection styles below AND the `selected_bg`
146+
# TuiTokens field — keep them wired so the two never drift.
147+
_SELECTED_BG_DARK = "#21243B"
148+
_SELECTED_BG_LIGHT = "#E7E9F9"
149+
144150
_PROMPT_STYLE_DARK = {
145151
"bottom-toolbar": "noreverse",
146152
# Input area — minimal: no background bar, only the prompt glyph is
@@ -159,10 +165,10 @@ def _task_browser_style_light() -> PTKStyle:
159165
"slash-completion-menu.command": "fg:#F4F4F5",
160166
"slash-completion-menu.command.match": "fg:#AFE3F1 bold",
161167
"slash-completion-menu.meta": "fg:#A3A3A3",
162-
"slash-completion-menu.command.current": "bg:#243C54 fg:#F4F4F5 bold",
163-
"slash-completion-menu.command.match.current": "bg:#243C54 fg:#AFE3F1 bold",
164-
"slash-completion-menu.meta.current": "bg:#243C54 fg:#A3A3A3",
165-
"slash-completion-menu.row.current": "bg:#243C54",
168+
"slash-completion-menu.command.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold",
169+
"slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_DARK} fg:#AFE3F1 bold",
170+
"slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_DARK} fg:#A3A3A3",
171+
"slash-completion-menu.row.current": f"bg:{_SELECTED_BG_DARK}",
166172
"file-completion-menu": "",
167173
"file-completion-menu.marker": "fg:#2B3A52",
168174
"file-completion-menu.marker.current": "fg:#AFE3F1 bold",
@@ -175,7 +181,7 @@ def _task_browser_style_light() -> PTKStyle:
175181
"shell-dialog.title": "fg:#F4F4F5 bold",
176182
"shell-dialog.border": "fg:#2B3A52",
177183
"shell-dialog.option": "fg:#A3A3A3",
178-
"shell-dialog.option.current": "bg:#243C54 fg:#F4F4F5 bold",
184+
"shell-dialog.option.current": f"bg:{_SELECTED_BG_DARK} fg:#F4F4F5 bold",
179185
"shell-footer.key": "fg:#AFE3F1 bold",
180186
"shell-footer.meta": "fg:#A3A3A3",
181187
"shell-footer.warning": "fg:#E6B450",
@@ -196,10 +202,10 @@ def _task_browser_style_light() -> PTKStyle:
196202
"slash-completion-menu.command": "fg:#4b5563",
197203
"slash-completion-menu.command.match": "fg:#176B7E bold",
198204
"slash-completion-menu.meta": "fg:#666666",
199-
"slash-completion-menu.command.current": "bg:#E6F2F6 fg:#213853 bold",
200-
"slash-completion-menu.command.match.current": "bg:#E6F2F6 fg:#176B7E bold",
201-
"slash-completion-menu.meta.current": "bg:#E6F2F6 fg:#666666",
202-
"slash-completion-menu.row.current": "bg:#E6F2F6",
205+
"slash-completion-menu.command.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold",
206+
"slash-completion-menu.command.match.current": f"bg:{_SELECTED_BG_LIGHT} fg:#176B7E bold",
207+
"slash-completion-menu.meta.current": f"bg:{_SELECTED_BG_LIGHT} fg:#666666",
208+
"slash-completion-menu.row.current": f"bg:{_SELECTED_BG_LIGHT}",
203209
"file-completion-menu": "",
204210
"file-completion-menu.marker": "fg:#8A93A0",
205211
"file-completion-menu.marker.current": "fg:#176B7E bold",
@@ -212,7 +218,7 @@ def _task_browser_style_light() -> PTKStyle:
212218
"shell-dialog.title": "fg:#213853 bold",
213219
"shell-dialog.border": "fg:#C8BEC0",
214220
"shell-dialog.option": "fg:#666666",
215-
"shell-dialog.option.current": "bg:#E6F2F6 fg:#213853 bold",
221+
"shell-dialog.option.current": f"bg:{_SELECTED_BG_LIGHT} fg:#213853 bold",
216222
"shell-footer.key": "fg:#176B7E bold",
217223
"shell-footer.meta": "fg:#666666",
218224
"shell-footer.warning": "fg:#9A6B18",
@@ -279,6 +285,8 @@ class MarkdownColors:
279285
inline_code: str
280286
link: str
281287
quote: str
288+
ordered_marker: str
289+
unordered_marker: str
282290
table_border: str
283291
code_block_border: str
284292
code_block_bg: str
@@ -287,18 +295,22 @@ class MarkdownColors:
287295
spinner_failed: str
288296

289297

290-
# Markdown/report role mapping: prose-heavy output stays professional and
291-
# low-chrome. Headings/strong text use primary text, emphasis/quotes use muted
292-
# grey, code/links use blue, and status accents stay green/red.
293-
# All values are derived from TuiTokens so there is a single source of truth.
298+
# Markdown/report role mapping. Headings/strong use primary text, emphasis and
299+
# unordered bullets use muted grey, status accents stay green/red — all derived
300+
# from TuiTokens. The four enumerated elements (inline code, links, blockquotes,
301+
# ordered-list markers) instead use terminal-native ANSI names so they adapt to
302+
# the user's terminal palette in both light and dark modes (see the design spec
303+
# 2026-06-08).
294304
def _build_markdown_colors(tokens: TuiTokens) -> MarkdownColors:
295305
return MarkdownColors(
296306
heading=tokens.tool_title,
297307
emphasis=tokens.muted,
298308
strong=tokens.tool_title,
299-
inline_code=tokens.info,
300-
link=tokens.info,
301-
quote=tokens.muted,
309+
inline_code="cyan", # terminal-native ANSI cyan
310+
link="cyan", # cyan, rendered underlined
311+
quote="green", # terminal-native ANSI green
312+
ordered_marker="bright_blue", # ordered markers take the bright-blue accent
313+
unordered_marker=tokens.muted, # unordered bullets stay muted
302314
table_border=tokens.border_muted,
303315
code_block_border=tokens.border_muted,
304316
code_block_bg=tokens.code_block_bg,
@@ -453,7 +465,6 @@ class TuiTokens:
453465
custom_message_text: str
454466
custom_message_label: str
455467
tool_pending_bg: str
456-
tool_success_bg: str
457468
tool_error_bg: str
458469
tool_title: str
459470
tool_output: str
@@ -471,9 +482,9 @@ class TuiTokens:
471482

472483

473484
_TUI_TOKENS_DARK = TuiTokens(
474-
accent="#5EA7E8",
485+
accent="#B3B9F4",
475486
border="#3A506D",
476-
border_accent="#AFE3F1",
487+
border_accent="#7C88DE",
477488
border_muted="#2B3A52",
478489
info="#AFE3F1",
479490
success="#7BC97F",
@@ -488,14 +499,13 @@ class TuiTokens:
488499
activity_verb_mid="#F4B5A5",
489500
activity_verb_highlight="#FBD9CE",
490501
activity_spinner="#B8C0CC",
491-
selected_bg="#243C54",
492-
user_message_bg="#1B2738",
502+
selected_bg=_SELECTED_BG_DARK,
503+
user_message_bg="#333333",
493504
user_message_text="",
494505
custom_message_bg="#16242E",
495506
custom_message_text="",
496507
custom_message_label="#AFE3F1",
497508
tool_pending_bg="#1B2230",
498-
tool_success_bg="#16271C",
499509
tool_error_bg="#2E1D24",
500510
tool_title="#F4F4F5",
501511
tool_output="#A3A3A3",
@@ -508,9 +518,9 @@ class TuiTokens:
508518

509519

510520
_TUI_TOKENS_LIGHT = TuiTokens(
511-
accent="#256EA8",
521+
accent="#0B114E",
512522
border="#495F7C",
513-
border_accent="#176B7E",
523+
border_accent="#3B469B",
514524
border_muted="#C8BEC0",
515525
info="#176B7E",
516526
success="#2C7A39",
@@ -525,14 +535,13 @@ class TuiTokens:
525535
activity_verb_mid="#B0573C",
526536
activity_verb_highlight="#8F3A26",
527537
activity_spinner="#6B7280",
528-
selected_bg="#E6F2F6",
529-
user_message_bg="#F0E4E4",
538+
selected_bg=_SELECTED_BG_LIGHT,
539+
user_message_bg="#E0E0E0",
530540
user_message_text="",
531541
custom_message_bg="#E6F2F6",
532542
custom_message_text="",
533543
custom_message_label="#176B7E",
534544
tool_pending_bg="#EFE7E8",
535-
tool_success_bg="#E4F0E6",
536545
tool_error_bg="#F6E3E3",
537546
tool_title="#213853",
538547
tool_output="#666666",

src/pythinker_code/utils/rich/markdown.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,9 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo
276276

277277
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
278278
render_options = options.update(width=options.max_width - 4)
279-
style = self.style.without_color
279+
# Keep the blockquote's own colour (blockquotes render green); the
280+
# ``▌`` bar and quoted text share it.
281+
style = self.style
280282
lines = console.render_lines(self.elements, render_options, style=style)
281283
new_line = Segment("\n")
282284
padding = Segment("▌ ", style)
@@ -478,7 +480,8 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo
478480
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
479481
indent_padding_len = LIST_INDENT_WIDTH * self.indent
480482
indent_text = " " * indent_padding_len
481-
bullet = Segment("• ")
483+
bullet_style = console.get_style("markdown.item.bullet", default="none")
484+
bullet = Segment("• ", bullet_style)
482485
new_line = Segment("\n")
483486
bullet_width = cell_len(bullet.text)
484487
child_width = max(1, options.max_width - indent_padding_len - bullet_width)
@@ -513,7 +516,8 @@ def render_number(
513516
indent_padding_len = LIST_INDENT_WIDTH * self.indent
514517
indent_text = " " * indent_padding_len
515518
numeral_text = f"{number}. "
516-
numeral = Segment(numeral_text)
519+
number_style = console.get_style("markdown.item.number", default="none")
520+
numeral = Segment(numeral_text, number_style)
517521
numeral_width = cell_len(numeral_text)
518522
child_width = max(1, options.max_width - indent_padding_len - numeral_width)
519523
lines = console.render_lines(
@@ -637,8 +641,6 @@ def enter_style(self, style_name: str | Style) -> Style:
637641
style = self.console.get_style(style_name, default=fallback)
638642
style = fallback + style
639643
style = style.copy()
640-
if isinstance(style_name, str) and style_name == "markdown.block_quote":
641-
style = style.without_color
642644
if (
643645
isinstance(style_name, str)
644646
and style_name in {"markdown.code", "markdown.code_block"}

0 commit comments

Comments
 (0)