Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ 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 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…"
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
Expand Down
13 changes: 11 additions & 2 deletions src/pythinker_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 21 additions & 3 deletions src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
63 changes: 62 additions & 1 deletion src/pythinker_code/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("_", "-")

Expand Down
7 changes: 6 additions & 1 deletion src/pythinker_code/soul/pythinkersoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 12 additions & 5 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/pythinker_code/ui/shell/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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(
Expand Down
14 changes: 13 additions & 1 deletion src/pythinker_code/ui/terminal_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"
Expand Down
34 changes: 32 additions & 2 deletions src/pythinker_code/utils/rich/diff_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,14 +161,44 @@ 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)
# Pygments appends a trailing newline (ensurenl=True); strip only that,
# 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(
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading