From f90a9315eb7e0081fd1d5973ad79dae59534d06f Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 22 Jun 2026 00:02:19 -0400 Subject: [PATCH 1/4] fix(tui): model-aware thinking levels, vscode truecolor, suppress reload banner Scope GPT-5 reasoning effort to each model's supported set, promote VS Code-family terminals to truecolor for diff tints, and skip the welcome splash on same-session reloads. --- CHANGELOG.md | 16 ++++ src/pythinker_code/app.py | 13 ++- src/pythinker_code/cli/__init__.py | 24 ++++- src/pythinker_code/llm.py | 63 ++++++++++++- src/pythinker_code/soul/pythinkersoul.py | 7 +- src/pythinker_code/ui/shell/__init__.py | 17 ++-- src/pythinker_code/ui/shell/slash.py | 9 +- .../ui/terminal_capabilities.py | 14 ++- tests/conftest.py | 9 ++ tests/core/test_model_thinking_levels.py | 89 +++++++++++++++++++ .../test_shell_run_placeholders.py | 29 ++++++ .../ui_and_conv/test_terminal_capabilities.py | 12 +++ 12 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 tests/core/test_model_thinking_levels.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 470f4f8a..e3f4f3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Reasoning levels now match each GPT model.** The thinking selector and + Shift+Tab cycle scope reasoning effort to what the active OpenAI GPT-5-family + model actually accepts — e.g. gpt-5.4/gpt-5.5 no longer offer `minimal` + (which they reject) and keep `xhigh`, while gpt-5.0 keeps `minimal`. A + persisted unsupported level is clamped to the nearest supported one before + it is sent, so the API never rejects it. +- **Diff backgrounds render correctly in VS Code-family terminals.** `color_depth()` + now promotes integrated terminals reporting `TERM_PROGRAM=vscode` (including + forks built on it) to truecolor — like the existing Windows Terminal + promotion — so diff add/remove tints no longer fall back to the colorless + 16-color path when the terminal doesn't advertise `COLORTERM`. +- **Model/theme switches no longer reprint the welcome banner.** A same-session + reload (`/model`, `/theme`, `/thinking`, `/new`, fork, …) now keeps the + existing banner and shows only its own "Switched to… / Reloading…" + confirmation, instead of stacking a redundant second welcome splash below it. + `/clear` and `/reload` still wipe the screen and reprint the banner. - **Update notice no longer crowds the prompt.** The persistent "Restart to apply" / "Update available" line now renders as the last footer row — below the status/clock line — instead of directly under the input box, keeping the input diff --git a/src/pythinker_code/app.py b/src/pythinker_code/app.py index 73a01f90..d05d8b3c 100644 --- a/src/pythinker_code/app.py +++ b/src/pythinker_code/app.py @@ -832,7 +832,11 @@ async def _mirror_external_cancel() -> None: await external_cancel_task async def run_shell( - self, command: str | None = None, *, prefill_text: str | None = None + self, + command: str | None = None, + *, + prefill_text: str | None = None, + suppress_banner: bool = False, ) -> bool: """Run the Pythinker CLI instance with shell UI.""" from pythinker_code.ui.shell import Shell, WelcomeInfoItem @@ -953,7 +957,12 @@ async def run_shell( ) ) async with self._env(): - shell = Shell(self._soul, welcome_info=welcome_info, prefill_text=prefill_text) + shell = Shell( + self._soul, + welcome_info=welcome_info, + prefill_text=prefill_text, + suppress_banner=suppress_banner, + ) return await shell.run(command) async def run_print( diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index 1c81a620..19d610f4 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -862,7 +862,12 @@ def _emit_fatal_error(message: str) -> None: # exception handler can clean it up even when _run() fails before returning. _latest_created_session: Session | None = None - async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple[Session, int]: + async def _run( + session_id: str | None, + prefill_text: str | None = None, + *, + suppress_banner: bool = False, + ) -> tuple[Session, int]: """ Create/load session and run the CLI instance. @@ -1056,7 +1061,11 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple try: match ui: case "shell": - shell_ok = await instance.run_shell(prompt, prefill_text=prefill_text) + shell_ok = await instance.run_shell( + prompt, + prefill_text=prefill_text, + suppress_banner=suppress_banner, + ) exit_code = ExitCode.SUCCESS if shell_ok else ExitCode.FAILURE case "print": exit_code = await instance.run_print( @@ -1190,10 +1199,15 @@ async def _reload_loop(session_id: str | None) -> tuple[str | None, int]: """ last_session: Session | None = None prefill_text: str | None = None + suppress_banner = False try: while True: try: - last_session, exit_code = await _run(session_id, prefill_text=prefill_text) + last_session, exit_code = await _run( + session_id, + prefill_text=prefill_text, + suppress_banner=suppress_banner, + ) break except Reload as e: if e.clear_screen: @@ -1220,6 +1234,10 @@ async def _reload_loop(session_id: str | None) -> tuple[str | None, int]: _print_resume_hint(old) session_id = e.session_id prefill_text = e.prefill_text + # A non-clearing reload (/model, /theme, /new, fork, …) leaves + # the previous banner on screen, so skip reprinting the splash. + # /clear and /reload wiped the screen above and want it back. + suppress_banner = not e.clear_screen continue except SwitchToWeb as e: # The web worker subprocess becomes the session's writer. diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 7134489f..ebbe8ba1 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -3,6 +3,8 @@ import contextlib import json import os +import re +from collections.abc import Collection from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast, get_args @@ -12,7 +14,9 @@ from pythinker_code.constant import USER_AGENT from pythinker_code.thinking import ( DEFAULT_THINKING_EFFORT, + available_thinking_levels, bool_to_thinking_effort, + clamp_thinking_effort, normalize_thinking_effort, thinking_effort_enabled, ) @@ -457,7 +461,15 @@ def create_llm( else DEFAULT_THINKING_EFFORT ) elif supports_thinking: - effective_effort = requested_effort + # Clamp to the model's actually-supported levels so a persisted effort + # the model rejects (e.g. ``minimal`` on gpt-5.4/5.5) is never sent. + effective_effort = ( + clamp_thinking_effort( + requested_effort, available_model_thinking_levels(model, capabilities) + ) + if requested_effort is not None + else None + ) else: # Clamp to the model's supported levels: non-reasoning models have # only the off level, so explicit non-off requests become off instead of @@ -594,6 +606,55 @@ def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]: return capabilities +_GPT5_REASONING_RE = re.compile(r"gpt-5(?:\.(\d+))?", re.IGNORECASE) + + +def openai_gpt_reasoning_levels(model_id: str) -> tuple[ThinkingEffort, ...] | None: + """Reasoning-effort levels an OpenAI GPT-5-family model actually accepts. + + OpenAI's ``reasoning_effort`` set is model-dependent and has drifted across + the GPT-5 line, so the provider-neutral ladder over-offers levels a given + model rejects (e.g. ``minimal`` on gpt-5.4/5.5). Returns the supported + levels low->high including ``off`` (OpenAI ``none``), or ``None`` when + *model_id* is not a recognized GPT-5 reasoning model. + + Matrix (OpenAI docs): + + * ``5.0`` -> minimal, low, medium, high + * ``5.1`` / ``5.2`` / ``5.3`` -> low, medium, high (``minimal`` replaced by ``none``) + * ``5.1-codex-max``, ``5.4+`` -> low, medium, high, xhigh (``minimal`` dropped) + """ + match = _GPT5_REASONING_RE.search(model_id) + if match is None: + return None + minor = int(match.group(1)) if match.group(1) else 0 + if minor == 0: + return ("off", "minimal", "low", "medium", "high") + if minor >= 4 or "codex-max" in model_id.lower(): + return ("off", "low", "medium", "high", "xhigh") + return ("off", "low", "medium", "high") + + +def available_model_thinking_levels( + model: LLMModel, capabilities: Collection[str] | None +) -> tuple[ThinkingEffort, ...]: + """Selectable thinking levels for *model*, scoped to provider-specific support. + + Starts from the capability-derived ladder, then narrows to a provider's + actually-accepted set when known (currently the OpenAI GPT-5 family) so the + selector never offers — and :func:`create_llm` never sends — a level the + model rejects. Falls back to the full ladder for models without a known + per-model rule. + """ + base = available_thinking_levels(capabilities) + gpt_levels = openai_gpt_reasoning_levels(model.model) + if gpt_levels is None: + return base + allowed = set(gpt_levels) + scoped: tuple[ThinkingEffort, ...] = tuple(level for level in base if level in allowed) + return scoped or base + + def _is_kimi_k2_model(model_name: str) -> bool: return "kimi-k2" in model_name.lower().replace("_", "-") diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 626279be..d0553ae0 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -924,7 +924,12 @@ def available_thinking_efforts(self) -> tuple[ThinkingEffort, ...]: """Selectable thinking levels for the current model.""" if self._runtime.llm is None: return ("off",) - return available_thinking_levels(self._runtime.llm.capabilities) + model = self._runtime.llm.model_config + if model is None: + return available_thinking_levels(self._runtime.llm.capabilities) + from pythinker_code.llm import available_model_thinking_levels + + return available_model_thinking_levels(model, self._runtime.llm.capabilities) def set_thinking_effort_from_manual(self, effort: ThinkingEffort) -> ThinkingEffort | None: """Apply a user-selected thinking level to the live runtime. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 9c6ea6ac..3156f0ec 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -560,10 +560,16 @@ def __init__( soul: Soul, welcome_info: list[WelcomeInfoItem] | None = None, prefill_text: str | None = None, + suppress_banner: bool = False, ): self.soul = soul self._welcome_info = list(welcome_info or []) self._prefill_text = prefill_text + # Skip the full welcome splash on an in-session reload (/model, /theme, + # /new, fork, …): the screen still shows the previous banner and each + # reload path prints its own "Switched to… / Reloading…" confirmation, + # so reprinting the splash just stacks a redundant copy below it. + self._suppress_banner = suppress_banner self._background_tasks: set[asyncio.Task[Any]] = set() self._prompt_session: CustomPromptSession | None = None # (timestamp, text) memo for the under-input update line; refreshed on a @@ -839,11 +845,12 @@ async def run(self, command: str | None = None) -> bool: # carries the blinking "connecting" heartbeat without ever # blocking input. await self.soul.start_background_mcp_loading() - _print_welcome_info( - self.soul.name or "Pythinker CLI", - self._welcome_info, - banner=_welcome_banner_chip(), - ) + if not self._suppress_banner: + _print_welcome_info( + self.soul.name or "Pythinker CLI", + self._welcome_info, + banner=_welcome_banner_chip(), + ) # Start telemetry periodic flush and disk retry from pythinker_code.telemetry import get_sink diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 781405b7..707d2b2b 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -303,11 +303,12 @@ async def model(app: Shell, args: str): # Step 2: Determine thinking effort capabilities = derive_model_capabilities(selected_model_cfg) - from pythinker_code.thinking import available_thinking_levels, clamp_thinking_effort + from pythinker_code.llm import available_model_thinking_levels + from pythinker_code.thinking import clamp_thinking_effort from pythinker_code.ui.shell.selectors.thinking import ThinkingLevel, run_thinking_selector native_thinking = model_uses_native_thinking(capabilities) - available_efforts = available_thinking_levels(capabilities) + available_efforts = available_model_thinking_levels(selected_model_cfg, capabilities) if native_thinking or available_efforts == ("off",): new_effort = "off" else: @@ -1224,7 +1225,6 @@ async def thinking(app: Shell, args: str) -> None: return from pythinker_code.thinking import ( - available_thinking_levels, clamp_thinking_effort, model_uses_native_thinking, ) @@ -1237,7 +1237,8 @@ async def thinking(app: Shell, args: str) -> None: console.print(f"[{_t_think.error}]LLM is not set.[/]") return capabilities = soul.runtime.llm.capabilities - available_efforts = available_thinking_levels(capabilities) + # Model-aware levels (scopes e.g. gpt-5.4/5.5 away from unsupported 'minimal'). + available_efforts = soul.available_thinking_efforts() if available_efforts == ("off",): if model_uses_native_thinking(capabilities): console.print( diff --git a/src/pythinker_code/ui/terminal_capabilities.py b/src/pythinker_code/ui/terminal_capabilities.py index c5189cfb..618b4f59 100644 --- a/src/pythinker_code/ui/terminal_capabilities.py +++ b/src/pythinker_code/ui/terminal_capabilities.py @@ -56,10 +56,15 @@ def color_depth(environ: Mapping[str, str] | None = None) -> ColorDepth: Detection order: an explicit ``FORCE_COLOR`` level wins, then ``COLORTERM`` truecolor advertising, then the Windows Terminal promotion (``WT_SESSION`` implies 24-bit support even when ``TERM`` is - conservative), then ``TERM`` itself. ``"none"`` mirrors + conservative), then the VS Code-family promotion (``TERM_PROGRAM=vscode``), + then ``TERM`` itself. ``"none"`` mirrors :func:`colors_disabled`. Rich does its own downgrade for printing; this helper exists for UI decisions Rich can't make for us (e.g. skipping background tints that quantize badly on 16-color terminals). + + Terminals advertise color support inconsistently (many never set + ``COLORTERM``, and it is not forwarded through ``sudo``/SSH/tmux), so + hard-coding known 24-bit terminals is the standard workaround. """ env = _env(environ) if colors_disabled(env): @@ -75,6 +80,13 @@ def color_depth(environ: Mapping[str, str] | None = None) -> ColorDepth: return "truecolor" if env.get("WT_SESSION"): return "truecolor" + # VS Code's integrated terminal — and forks built on it, which keep + # TERM_PROGRAM=vscode — is xterm.js-based with 24-bit color, but some builds + # ship without COLORTERM. Promote it like Windows Terminal above so diff + # tints and other backgrounds don't fall back to the 16-color path. Comes + # after FORCE_COLOR so an explicit downgrade is still honored. + if _clean(env.get("TERM_PROGRAM")) == "vscode": + return "truecolor" term = _clean(env.get("TERM")) if "truecolor" in term or "direct" in term: return "truecolor" diff --git a/tests/conftest.py b/tests/conftest.py index 715e3f53..7338190a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,15 @@ "PYTHINKER_REDUCED_MOTION", "PYTHINKER_NO_ANIMATION", "PYTHINKER_STATIC_OUTPUT", + # Drop every truecolor-promoting signal so the pinned ``TERM`` below fixes + # the color tier at 256 (matching CI). A dev shell — e.g. a VS Code-family + # terminal — leaks ``COLORTERM``/``TERM_PROGRAM`` that ``color_depth()`` + # honors, which would otherwise flip the tier to truecolor and break the + # 256-tier shimmer/motion contract tests. Truecolor tests opt in by setting + # ``COLORTERM`` explicitly. + "COLORTERM", + "WT_SESSION", + "TERM_PROGRAM", ): os.environ.pop(_capability_var, None) os.environ["TERM"] = "xterm-256color" diff --git a/tests/core/test_model_thinking_levels.py b/tests/core/test_model_thinking_levels.py new file mode 100644 index 00000000..fa49e481 --- /dev/null +++ b/tests/core/test_model_thinking_levels.py @@ -0,0 +1,89 @@ +"""Per-model reasoning-effort scoping (OpenAI GPT-5 family). + +OpenAI's ``reasoning_effort`` set is model-dependent and drifted across the +GPT-5 line, so the provider-neutral ladder over-offers levels a given model +rejects (e.g. ``minimal`` on gpt-5.4/5.5). These tests pin the per-model rule +the selector and ``create_llm`` use so unsupported levels are never offered nor +sent. +""" + +from __future__ import annotations + +from pythinker_code.config import LLMModel +from pythinker_code.llm import ( + available_model_thinking_levels, + openai_gpt_reasoning_levels, +) +from pythinker_code.thinking import clamp_thinking_effort + + +def test_openai_gpt_reasoning_levels_per_version() -> None: + # 5.0 generation keeps 'minimal' (its lowest tier), no 'xhigh'. + assert openai_gpt_reasoning_levels("gpt-5") == ("off", "minimal", "low", "medium", "high") + assert openai_gpt_reasoning_levels("gpt-5-codex") == ( + "off", + "minimal", + "low", + "medium", + "high", + ) + # 5.1 replaced 'minimal' with 'none', still no 'xhigh'. + assert openai_gpt_reasoning_levels("gpt-5.1") == ("off", "low", "medium", "high") + # codex-max and 5.4+ add 'xhigh' and drop 'minimal'. + assert openai_gpt_reasoning_levels("gpt-5.1-codex-max") == ( + "off", + "low", + "medium", + "high", + "xhigh", + ) + assert openai_gpt_reasoning_levels("gpt-5.4-mini") == ("off", "low", "medium", "high", "xhigh") + assert openai_gpt_reasoning_levels("gpt-5.5") == ("off", "low", "medium", "high", "xhigh") + # A provider-prefixed id (e.g. OpenRouter) still matches. + assert openai_gpt_reasoning_levels("openai/gpt-5.5") == ( + "off", + "low", + "medium", + "high", + "xhigh", + ) + # Models without a known per-model rule opt out. + assert openai_gpt_reasoning_levels("claude-opus-4") is None + assert openai_gpt_reasoning_levels("glm-5.2") is None + + +def test_available_model_thinking_levels_scopes_gpt() -> None: + caps = {"thinking"} + gpt55 = LLMModel(provider="openai", model="gpt-5.5", max_context_size=400_000) + levels = available_model_thinking_levels(gpt55, caps) + assert "minimal" not in levels + assert levels == ("off", "low", "medium", "high", "xhigh") + + gpt5 = LLMModel(provider="openai", model="gpt-5", max_context_size=400_000) + assert available_model_thinking_levels(gpt5, caps) == ( + "off", + "minimal", + "low", + "medium", + "high", + ) + + +def test_available_model_thinking_levels_non_gpt_keeps_full_ladder() -> None: + other = LLMModel(provider="anthropic", model="claude-opus-4", max_context_size=200_000) + # No per-model rule -> full provider-neutral ladder preserved. + assert available_model_thinking_levels(other, {"thinking"}) == ( + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + ) + + +def test_unsupported_effort_clamps_up_to_supported() -> None: + # The create_llm send-path clamps a persisted unsupported effort to the + # nearest supported level, so 'minimal' is never sent to gpt-5.5. + gpt55_levels = ("off", "low", "medium", "high", "xhigh") + assert clamp_thinking_effort("minimal", gpt55_levels) == "low" diff --git a/tests/ui_and_conv/test_shell_run_placeholders.py b/tests/ui_and_conv/test_shell_run_placeholders.py index 543eb79f..ffa92153 100644 --- a/tests/ui_and_conv/test_shell_run_placeholders.py +++ b/tests/ui_and_conv/test_shell_run_placeholders.py @@ -421,3 +421,32 @@ async def test_shell_run_exits_immediately_for_visible_slash_quit_command_in_she shell._run_shell_command.assert_not_awaited() shell._run_slash_command.assert_not_awaited() assert printed == ["Bye!"] + + +@pytest.mark.asyncio +async def test_shell_run_prints_welcome_banner_by_default(monkeypatch, _patched_shell_run) -> None: + banner_calls: list[int] = [] + monkeypatch.setattr(shell_module, "_print_welcome_info", lambda *a, **k: banner_calls.append(1)) + _FakePromptSession.responses = deque([EOFError()]) + shell = shell_module.Shell(cast(Soul, _make_fake_soul())) + + await shell.run() + + assert banner_calls == [1] + + +@pytest.mark.asyncio +async def test_shell_run_suppresses_welcome_banner_on_reload( + monkeypatch, _patched_shell_run +) -> None: + # A same-session reload (/model, /theme, /new, fork, …) leaves the previous + # banner on screen and prints its own confirmation, so the splash must not + # be reprinted — otherwise it stacks a redundant copy below the old one. + banner_calls: list[int] = [] + monkeypatch.setattr(shell_module, "_print_welcome_info", lambda *a, **k: banner_calls.append(1)) + _FakePromptSession.responses = deque([EOFError()]) + shell = shell_module.Shell(cast(Soul, _make_fake_soul()), suppress_banner=True) + + await shell.run() + + assert banner_calls == [] diff --git a/tests/ui_and_conv/test_terminal_capabilities.py b/tests/ui_and_conv/test_terminal_capabilities.py index fdc9e838..5b22196e 100644 --- a/tests/ui_and_conv/test_terminal_capabilities.py +++ b/tests/ui_and_conv/test_terminal_capabilities.py @@ -62,6 +62,17 @@ def test_color_depth_windows_terminal_promotion() -> None: assert color_depth({"WT_SESSION": "guid", "FORCE_COLOR": "2"}) == "256" +def test_color_depth_vscode_family_promotion() -> None: + # VS Code-family integrated terminals (TERM_PROGRAM=vscode, including forks) + # are truecolor-capable even when they don't advertise COLORTERM and TERM is + # conservative — promote them so diff tints don't fall to the 16-color path. + assert color_depth({"TERM_PROGRAM": "vscode", "TERM": "xterm-256color"}) == "truecolor" + assert color_depth({"TERM_PROGRAM": "vscode", "TERM": "xterm"}) == "truecolor" + # Disabled color and explicit FORCE_COLOR downgrades still win over the promotion. + assert color_depth({"TERM_PROGRAM": "vscode", "NO_COLOR": "1"}) == "none" + assert color_depth({"TERM_PROGRAM": "vscode", "FORCE_COLOR": "2"}) == "256" + + def test_diff_colors_fall_back_to_foregrounds_on_16_color(monkeypatch) -> None: from pythinker_code.ui.theme import get_diff_colors @@ -71,6 +82,7 @@ def test_diff_colors_fall_back_to_foregrounds_on_16_color(monkeypatch) -> None: "CLICOLOR", "COLORTERM", "WT_SESSION", + "TERM_PROGRAM", "FORCE_COLOR", ): monkeypatch.delenv(var, raising=False) From e47fa6ca0c56565e1632d2eb4984a9d7e56e512d Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 22 Jun 2026 11:36:47 -0400 Subject: [PATCH 2/4] fix(tui): strip syntax theme background from diff highlights Code-theme backgrounds masked diff row tints and caused a blue overlay in VS Code-family terminals; drop bg on highlighted spans so add/remove tints and the terminal background show through. --- CHANGELOG.md | 16 ++++++--- src/pythinker_code/utils/rich/diff_render.py | 34 ++++++++++++++++++-- tests/utils/test_diff_render.py | 26 +++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f4f3cd..3cc4fb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,17 @@ GitHub Releases page; `0.8.0` is the new starting line. (which they reject) and keep `xhigh`, while gpt-5.0 keeps `minimal`. A persisted unsupported level is clamped to the nearest supported one before it is sent, so the API never rejects it. -- **Diff backgrounds render correctly in VS Code-family terminals.** `color_depth()` - now promotes integrated terminals reporting `TERM_PROGRAM=vscode` (including - forks built on it) to truecolor — like the existing Windows Terminal - promotion — so diff add/remove tints no longer fall back to the colorless - 16-color path when the terminal doesn't advertise `COLORTERM`. +- **Diff cards no longer show a "blue overlay".** The syntax highlighter no + longer paints the code theme's opaque background (e.g. catppuccin `#1E1E2E`) + onto diff lines, so the green/red row tints — and the terminal background on + context lines — show through on every terminal. Previously that code-theme + block masked the row tints and only blended where the terminal background + happened to match it. +- **VS Code-family terminals are detected as truecolor.** `color_depth()` now + promotes integrated terminals reporting `TERM_PROGRAM=vscode` (including forks + built on it) to truecolor — like the existing Windows Terminal promotion — so + diff tints don't fall back to the colorless 16-color path when the terminal + doesn't advertise `COLORTERM`. - **Model/theme switches no longer reprint the welcome banner.** A same-session reload (`/model`, `/theme`, `/thinking`, `/new`, fork, …) now keeps the existing banner and shows only its own "Switched to… / Reloading…" diff --git a/src/pythinker_code/utils/rich/diff_render.py b/src/pythinker_code/utils/rich/diff_render.py index ee58b046..0769eace 100644 --- a/src/pythinker_code/utils/rich/diff_render.py +++ b/src/pythinker_code/utils/rich/diff_render.py @@ -17,7 +17,7 @@ from rich.panel import Panel from rich.style import Style as RichStyle from rich.table import Table -from rich.text import Text +from rich.text import Span, Text from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.theme import get_diff_colors, tui_rich_style @@ -161,6 +161,36 @@ def _cached_diff_highlighter(lexer: str, theme: str) -> PythinkerSyntax: return PythinkerSyntax("", lexer, theme=resolve_code_theme(theme)) +def _without_bg(style: RichStyle | str) -> RichStyle | str: + """Return *style* with its background color dropped (foreground kept).""" + if not isinstance(style, RichStyle) or style.bgcolor is None: + return style + return RichStyle( + color=style.color, + bold=style.bold, + dim=style.dim, + italic=style.italic, + underline=style.underline, + strike=style.strike, + reverse=style.reverse, + ) + + +def _strip_background(text: Text) -> Text: + """Drop syntax-theme backgrounds in place. + + The code theme paints its base color (e.g. catppuccin ``#1E1E2E``) onto + every cell. Inside a diff that opaque block masks the green/red row tints + and only blends on terminals whose own background happens to match it. + Removing it lets the diff row's add/remove tint — and the terminal + background on context lines — show through on every terminal. + """ + if isinstance(text.style, RichStyle): + text.style = _without_bg(text.style) + text.spans = [Span(span.start, span.end, _without_bg(span.style)) for span in text.spans] + return text + + def highlight_diff_code(highlighter: PythinkerSyntax, code: str) -> Text: """Syntax-highlight a single diff code line (no row/inline diff styling).""" t = highlighter.highlight(code) @@ -168,7 +198,7 @@ def highlight_diff_code(highlighter: PythinkerSyntax, code: str) -> Text: # not trailing whitespace which may be meaningful in diffs. if t.plain.endswith("\n"): t.right_crop(1) - return t + return _strip_background(t) def apply_inline_diff_highlights( diff --git a/tests/utils/test_diff_render.py b/tests/utils/test_diff_render.py index 5c53be1d..f598b779 100644 --- a/tests/utils/test_diff_render.py +++ b/tests/utils/test_diff_render.py @@ -16,6 +16,8 @@ _highlight_hunk, _make_highlighter, collect_diff_hunks, + highlight_diff_code, + make_diff_highlighter, render_diff_panel, render_diff_preview, ) @@ -652,3 +654,27 @@ def test_fallback_highlighting(self, path: str) -> None: """Unknown/missing extension should not crash — falls back to plain text.""" hunks, a, r = collect_diff_hunks([_make_block(path=path, old_text="a", new_text="b")]) _render_to_text(render_diff_panel(path, hunks, a, r)) + + +def test_highlight_diff_code_carries_no_background() -> None: + """Syntax-highlighted diff code must not paint the code-theme background. + + The catppuccin theme sets an opaque base color; if it leaked onto diff + cells it would mask the row's add/remove tint and only blend on terminals + whose own background matched it (the "blue overlay" bug). The diff row tint + and the terminal background on context lines must show through. + """ + from pythinker_code.utils.rich.syntax import set_active_code_theme + + set_active_code_theme("catppuccin-adaptive") + highlighter = make_diff_highlighter("snippet.py") + text = highlight_diff_code(highlighter, "import os") + + assert not (isinstance(text.style, RichStyle) and text.style.bgcolor is not None) + for span in text.spans: + if isinstance(span.style, RichStyle): + assert span.style.bgcolor is None, "diff syntax span must not carry a background" + # Foreground token colors are still applied. + assert any( + isinstance(span.style, RichStyle) and span.style.color is not None for span in text.spans + ) From 6bead1234e2886afee098a5d3f10db82afe0badf Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 22 Jun 2026 14:12:21 -0400 Subject: [PATCH 3/4] test(diff): restore code theme after diff_render tests Add autouse fixture so set_active_code_theme in background-stripping test does not leak process-global state between tests. --- tests/utils/test_diff_render.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/utils/test_diff_render.py b/tests/utils/test_diff_render.py index f598b779..53db1d91 100644 --- a/tests/utils/test_diff_render.py +++ b/tests/utils/test_diff_render.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Iterator + import pytest from rich.console import Console from rich.style import Style as RichStyle @@ -34,6 +36,18 @@ def _restore_active_theme(): set_active_theme(saved) +@pytest.fixture(autouse=True) +def _restore_active_code_theme() -> Iterator[None]: + """Keep the process-wide code theme from leaking between tests.""" + from pythinker_code.utils.rich.syntax import get_active_code_theme, set_active_code_theme + + saved = get_active_code_theme() + try: + yield + finally: + set_active_code_theme(saved) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- From 2a7b8f1f5162357a59e5c1dc0b147ade1ea13a80 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Mon, 22 Jun 2026 14:20:51 -0400 Subject: [PATCH 4/4] chore: trigger CI on latest PR head Refresh required checks after test fixture commit.