diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf09924..ffb1e5bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Thinking effort controls and safer auto-mode decisions.** Thinking effort is now a first-class setting across the CLI, ACP, web config, and supported providers; Shift+Tab cycles available efforts in the shell, and auto-mode can deliberate with advisor feedback before sensitive or destructive approval flows. - **Shell sessions get cleaner recaps and rendering.** The interactive shell can show turn recaps, includes hook stdout/stderr in the transcript, improves prompt/file-mention and tool-output spacing, and uses branded browser-login result pages. - **MiniMax Token Plan model availability stays current.** MiniMax login and startup refresh now use the authenticated model catalog so Token Plan keys only keep models actually available to that key, while preserving user model preferences and isolating discovery failures from other provider refreshes. diff --git a/docs/superpowers/specs/2026-06-01-shimmer-traveling-waves-design.md b/docs/superpowers/specs/2026-06-01-shimmer-traveling-waves-design.md new file mode 100644 index 00000000..5a3ebb66 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-shimmer-traveling-waves-design.md @@ -0,0 +1,130 @@ +# Shimmer traveling-waves redesign + +**Date:** 2026-06-01 +**Status:** Approved design, pending implementation +**Scope:** `src/pythinker_code/ui/shell/motion.py` (plus tests) + +## Problem + +The active-work shimmer (`_shimmer_segments`) sweeps a single bright highlight +right-to-left across a label, then jumps back and repeats in the same direction. +It loops in one direction only — there is no sense of the light bouncing or +reaching the end of the word. + +We want the shimmer to read like **traveling waves**: a wave crosses the word, +splashes outward from the middle when it reaches the end, then a wave travels +back the other way, splashes again, and repeats. + +## Goal + +Restructure the per-character shimmer into a four-phase loop while keeping: + +- the existing 3-color palette (`#D49E5A` muted orange-yellow base / `#E2C18A` + warm sheen-trail mid / `#D8DCE2` silver highlight) and + `_SHIMMER_INTERVAL_S = 0.22` tick; +- the **purely time-derived** model — every frame is a function of `elapsed_s` + alone, so the prompt, activity tree, and pinned-todo renderers stay in sync + with no shared animation state; +- the public surface: `shimmer_text`, `shimmer_prompt_fragments`, and the + per-char path inside `activity_status_line` call the same engine with no + signature changes; +- the `colors_disabled()` (plain text) and reduced-motion (static base amber) + short-circuits exactly as today. + +## Animation cycle + +`L = len(label)`. One loop is four phases, indexed by +`frame = int(max(0.0, elapsed_s) / 0.22)`, `phase_index = frame % CYCLE_LEN`. + +| Phase | Name | Behavior | Frames | +|-------|-----------------|-------------------------------------------------------------|------------------| +| A | Wave → (R→L) | Current sweep, unchanged: violet head + asymmetric coral trail | `L + 6` | +| B | Splash | Wave blooms from center char outward to both edges, settles | `ceil(L/2) + 3` | +| C | Wave ← (L→R) | Mirror of A: head travels the other way, trail flips side | `L + 6` | +| D | Splash | Same center-out bloom as B | `ceil(L/2) + 3` | + +`CYCLE_LEN = 2*(L + 6) + 2*(ceil(L/2) + 3)`. After phase D the loop returns to A. + +### Phase A — wave right-to-left (preserve current look) + +Unchanged from today: `head = L + 2 - local_phase`; for each non-space char at +index `i`, `offset = i - head`: + +- `offset == 0` → highlight +- `offset in (-1, 1, 2, 3)` → mid (asymmetric trailing edge) +- else → base + +### Phase C — wave left-to-right (mirror) + +`head` travels from the left edge to past the right edge as `local_phase` +increases. The trail is mirrored to the opposite side so the sheen still trails +*behind* the direction of travel: + +- `offset == 0` → highlight +- `offset in (1, -1, -2, -3)` → mid +- else → base + +### Phase B / D — splash (center-out traveling wave) + +`center = (L - 1) / 2` (fractional for even `L`). On local splash frame `f` +(0-based), wavefront radius `r = f`. For each non-space char at index `i`, +`d = abs(i - center)`. The wavefront is a half-cell band so odd and even +lengths behave identically: + +- `r - 0.5 <= d <= r + 0.5` → highlight — the expanding wavefront +- `d < r - 0.5` → mid — already-filled interior +- `d > r + 0.5` → base — not yet reached + +For even `L` the two center chars (`d == 0.5`) light up together on `f == 0`; +for odd `L` the single center char (`d == 0`) lights up on `f == 0`. + +The final settle frames (after the wavefront passes both edges) paint the whole +word base amber, giving a brief calm beat before the next wave launches. + +Spaces remain uncolored (`None`) in every phase, exactly as today. + +## Implementation shape + +Refactor `_shimmer_segments(label, elapsed_s, *, reduced_motion)` into a small +dispatcher: + +- keep the early returns (`not label`, `colors_disabled`, reduced-motion); +- compute `L`, the four phase lengths, `CYCLE_LEN`, and `phase_index`; +- delegate to one of two helpers that return a `list[str | None]` of per-char + colors: + - `_wave_colors(chars, local_phase, direction)` — phases A and C; + - `_splash_colors(chars, local_phase)` — phases B and D; +- coalesce equal-color runs into `(color, text)` segments (existing logic). + +No changes to `shimmer_text`, `shimmer_prompt_fragments`, +`shimmer_spinner_style`, or any call site. + +## Edge cases + +- `L == 0` → `[]` (existing guard). +- `L == 1` → `center == 0`, splash highlights the single char on `f == 0` then + settles; waves degenerate gracefully (single char cycles base/mid/highlight). +- Labels with spaces / multi-word ("Reticulating splines") → positional math is + unaffected; spaces stay `None`. + +## Verification (TDD) + +New tests in `tests/ui_and_conv/test_shell_motion_shimmer.py`: + +1. **Splash originates at center and widens** — at a splash-phase frame, the + highlighted indices are centered and the highlighted/filled span grows over + consecutive frames. +2. **Phase C trail is mirrored vs phase A** — for a head at the same offset, the + mid-colored trail sits on the opposite side. +3. **Cycle returns to start** — colors at `frame` and `frame + CYCLE_LEN` + (for a fixed label) are identical. +4. **Palette + plain-text invariants preserved** — existing three-color and + reduced-motion assertions still pass. + +Plus: `make check-pythinker-code` (ruff check + ruff format) green. + +## Out of scope + +- `shimmer_spinner_style` (single-color whole-word path) keeps its current + simple 4-step palette cycle. +- No new config flags, no palette changes, no timing knobs exposed. diff --git a/packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py b/packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py index a12a6028..01c8d6a0 100644 --- a/packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py +++ b/packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py @@ -119,9 +119,13 @@ def input(self) -> int: return self.input_other + self.input_cache_read + self.input_cache_creation -type ThinkingEffort = Literal["off", "low", "medium", "high", "xhigh", "max"] +type ThinkingEffort = Literal["off", "minimal", "low", "medium", "high", "xhigh", "max"] """The effort level for thinking. +``minimal`` is the lowest user-facing reasoning level and maps to providers +that support it natively (for example OpenAI reasoning_effort) or to the +smallest available budget/effort otherwise. + Support for levels above ``high`` varies by provider: - **Anthropic**: ``xhigh`` is accepted only on Claude Opus 4.7; ``max`` is diff --git a/packages/pythinker-core/src/pythinker_core/chat_provider/openai_common.py b/packages/pythinker-core/src/pythinker_core/chat_provider/openai_common.py index eb43ec4f..1512de31 100644 --- a/packages/pythinker-core/src/pythinker_core/chat_provider/openai_common.py +++ b/packages/pythinker-core/src/pythinker_core/chat_provider/openai_common.py @@ -119,6 +119,8 @@ def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffo match effort: case "off": return None + case "minimal": + return "minimal" case "low": return "low" case "medium": @@ -137,7 +139,9 @@ def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffo def reasoning_effort_to_thinking_effort(effort: ReasoningEffort) -> ThinkingEffort: match effort: - case "low" | "minimal": + case "minimal": + return "minimal" + case "low": return "low" case "medium": return "medium" diff --git a/packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py b/packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py index d2998cb6..0f14d682 100644 --- a/packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py +++ b/packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py @@ -140,6 +140,8 @@ def thinking_effort(self) -> ThinkingEffort | None: if reasoning_effort is None: return None match reasoning_effort: + case "minimal": + return "minimal" case "low": return "low" case "medium": @@ -197,6 +199,8 @@ def with_thinking(self, effort: ThinkingEffort) -> Self: match effort: case "off": reasoning_effort = None + case "minimal": + reasoning_effort = "minimal" case "low": reasoning_effort = "low" case "medium": diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py index 490684bb..7180ca07 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py @@ -175,6 +175,11 @@ def _clamp_effort(effort: "ThinkingEffort", model: str) -> "ThinkingEffort": return effort if effort in _supported_efforts(model): return effort + if effort == "minimal": + # Anthropic has no 'minimal' effort; 'low' is the floor of every + # _supported_efforts() set. Map down to it rather than clamping up to + # 'high', which would request a far larger budget than the user asked for. + return "low" return "high" diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/google_genai.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/google_genai.py index 66634a3a..dc8fadc8 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/google_genai.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/google_genai.py @@ -178,7 +178,8 @@ def with_thinking(self, effort: "ThinkingEffort") -> Self: case "off": # use default thinking config pass - case "low": + case "minimal" | "low": + # Gemini has no 'minimal'; map to its lowest level. thinking_config.thinking_level = ThinkingLevel.LOW case "medium": # FIXME: medium not supported yet, use high @@ -191,7 +192,8 @@ def with_thinking(self, effort: "ThinkingEffort") -> Self: case "off": thinking_config.thinking_budget = 0 thinking_config.include_thoughts = False - case "low": + case "minimal" | "low": + # Gemini has no 'minimal'; use its lowest thinking budget. thinking_config.thinking_budget = 1024 thinking_config.include_thoughts = True case "medium": diff --git a/packages/pythinker-core/tests/api_snapshot_tests/test_pythinker.py b/packages/pythinker-core/tests/api_snapshot_tests/test_pythinker.py index 94b9c3b3..6572705f 100644 --- a/packages/pythinker-core/tests/api_snapshot_tests/test_pythinker.py +++ b/packages/pythinker-core/tests/api_snapshot_tests/test_pythinker.py @@ -467,3 +467,21 @@ async def test_pythinker_with_extra_body_non_thinking_key_shallow_merge(): pass body = json.loads(mock.calls.last.request.content.decode()) assert body["custom"] == snapshot({"b": 2}) + + +def test_with_thinking_minimal_round_trips(): + from pythinker_core.chat_provider.pythinker import Pythinker + + provider = Pythinker(model="pythinker-ai", api_key="test-key", stream=False).with_thinking( + "minimal" + ) + assert provider.thinking_effort == "minimal" + + +def test_with_thinking_low_unchanged(): + from pythinker_core.chat_provider.pythinker import Pythinker + + provider = Pythinker(model="pythinker-ai", api_key="test-key", stream=False).with_thinking( + "low" + ) + assert provider.thinking_effort == "low" diff --git a/packages/pythinker-core/tests/test_anthropic_thinking.py b/packages/pythinker-core/tests/test_anthropic_thinking.py index 4076f691..274eea54 100644 --- a/packages/pythinker-core/tests/test_anthropic_thinking.py +++ b/packages/pythinker-core/tests/test_anthropic_thinking.py @@ -103,6 +103,11 @@ def test_supports_adaptive_thinking(model: str, expected: bool) -> None: ("claude-opus-4-7", "low", "low"), ("claude-opus-4-6", "medium", "medium"), ("claude-sonnet-4-20250514", "low", "low"), + # minimal has no Anthropic equivalent; clamp DOWN to the lowest ('low'), + # not up to 'high' (which would request a far larger budget). + ("claude-opus-4-5", "minimal", "low"), + ("claude-sonnet-4-6", "minimal", "low"), + ("claude-opus-4-7", "minimal", "low"), # Future 4.8+ inherits Opus 4.7-like behavior only if name signals opus-4-7+ # 4.8 is not automatically assumed to support xhigh; only guaranteed max. ("claude-opus-4-8", "xhigh", "high"), diff --git a/packages/pythinker-core/tests/test_openai_common.py b/packages/pythinker-core/tests/test_openai_common.py index eb79125d..113c0b2e 100644 --- a/packages/pythinker-core/tests/test_openai_common.py +++ b/packages/pythinker-core/tests/test_openai_common.py @@ -22,14 +22,16 @@ class TestThinkingEffortMapping: """OpenAI's reasoning_effort accepts: none, minimal, low, medium, high, xhigh (xhigh added for models after gpt-5.1-codex-max). Pythinker Core's ThinkingEffort - is: off, low, medium, high, xhigh, max. The bidirectional mapping must - preserve xhigh round-trip and clamp max sensibly. + is: off, minimal, low, medium, high, xhigh, max. The bidirectional mapping must + preserve the minimal and xhigh round-trips and clamp max sensibly. """ @pytest.mark.parametrize( "thinking_effort,expected_reasoning", [ ("off", None), + # OpenAI supports minimal natively — first-class round-trip. + ("minimal", "minimal"), ("low", "low"), ("medium", "medium"), ("high", "high"), @@ -56,7 +58,8 @@ def test_thinking_to_reasoning( [ (None, "off"), ("none", "off"), - ("minimal", "low"), + # OpenAI supports minimal natively — first-class round-trip. + ("minimal", "minimal"), ("low", "low"), ("medium", "medium"), ("high", "high"), diff --git a/src/pythinker_code/acp/server.py b/src/pythinker_code/acp/server.py index 50fa45f7..0662ed5b 100644 --- a/src/pythinker_code/acp/server.py +++ b/src/pythinker_code/acp/server.py @@ -24,6 +24,7 @@ from pythinker_code.session import Session from pythinker_code.soul.slash import registry as soul_slash_registry from pythinker_code.soul.toolset import PythinkerToolset +from pythinker_code.thinking import DEFAULT_THINKING_EFFORT, effective_config_thinking_effort from pythinker_code.utils.logging import logger @@ -380,11 +381,21 @@ async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) ) raise acp.RequestError.invalid_params({"model_id": "Model's provider not found"}) + if model_id_conv.thinking: + # Preserve the user's configured effort when switching to a thinking + # model; only fall back to the default when thinking was previously off. + current_effort = effective_config_thinking_effort( + config.default_thinking, config.default_thinking_effort + ) + thinking_effort = current_effort if current_effort != "off" else DEFAULT_THINKING_EFFORT + else: + thinking_effort = "off" new_llm = create_llm( new_provider, new_model, session_id=acp_session.id, thinking=model_id_conv.thinking, + thinking_effort=thinking_effort, oauth=cli_instance.soul.runtime.oauth, ) cli_instance.soul.runtime.llm = new_llm @@ -392,12 +403,14 @@ async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) config.default_model = model_id_conv.model_key config.default_thinking = model_id_conv.thinking + config.default_thinking_effort = thinking_effort assert config.is_from_default_location, ( "`pythinker acp` must use the default config location" ) config_for_save = load_config() config_for_save.default_model = model_id_conv.model_key config_for_save.default_thinking = model_id_conv.thinking + config_for_save.default_thinking_effort = thinking_effort save_config(config_for_save) async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateResponse | None: diff --git a/src/pythinker_code/app.py b/src/pythinker_code/app.py index fec163a4..675137a8 100644 --- a/src/pythinker_code/app.py +++ b/src/pythinker_code/app.py @@ -12,6 +12,7 @@ import pythinker_host from pydantic import SecretStr +from pythinker_core.chat_provider import ThinkingEffort from pythinker_host.path import HostPath from pythinker_code.agentspec import DEFAULT_AGENT_FILE @@ -27,6 +28,7 @@ from pythinker_code.soul.agent import Runtime, load_agent from pythinker_code.soul.context import Context from pythinker_code.soul.pythinkersoul import PythinkerSoul +from pythinker_code.thinking import bool_to_thinking_effort, effective_config_thinking_effort from pythinker_code.utils.aioqueue import QueueShutDown from pythinker_code.utils.logging import logger, open_original_stderr, redirect_stderr_to_logger from pythinker_code.utils.path import shorten_home @@ -148,6 +150,7 @@ async def create( config: Config | Path | None = None, model_name: str | None = None, thinking: bool | None = None, + thinking_effort: ThinkingEffort | None = None, # Run mode yolo: bool = False, auto: bool = False, @@ -176,6 +179,8 @@ async def create( Defaults to None. model_name (str | None, optional): Name of the model to use. Defaults to None. thinking (bool | None, optional): Whether to enable thinking mode. Defaults to None. + thinking_effort (ThinkingEffort | None, optional): Reasoning effort override. + Defaults to None. yolo (bool, optional): Dangerously skip permission approvals. The user is still reachable via ``AskUserQuestion``. Defaults to False. auto (bool, optional): Invocation-level auto mode (no user is present to answer @@ -256,8 +261,19 @@ async def create( assert model is not None env_overrides = augment_provider_with_env_vars(provider, model, provider_key=model.provider) - # determine thinking mode - thinking = config.default_thinking if thinking is None else thinking + # determine thinking mode / effort. The bool flag is kept for CLI and + # config compatibility; the effort string is the source of truth for new + # sessions when present. + if thinking_effort is None: + thinking_effort = ( + bool_to_thinking_effort(thinking) + if thinking is not None + else effective_config_thinking_effort( + config.default_thinking, + config.default_thinking_effort, + ) + ) + thinking = thinking_effort != "off" # determine yolo mode yolo = yolo if yolo else config.default_yolo @@ -270,6 +286,7 @@ async def create( provider, model, thinking=thinking, + thinking_effort=thinking_effort, session_id=session.id, oauth=oauth, ) @@ -277,6 +294,7 @@ async def create( logger.info("Using LLM provider: {provider}", provider=provider) logger.info("Using LLM model: {model}", model=model) logger.info("Thinking mode: {thinking}", thinking=thinking) + logger.info("Thinking effort: {thinking_effort}", thinking_effort=thinking_effort) if startup_progress is not None: startup_progress("Scanning workspace...") diff --git a/src/pythinker_code/auth/anthropic_direct.py b/src/pythinker_code/auth/anthropic_direct.py index ed043cf5..8fcbb772 100644 --- a/src/pythinker_code/auth/anthropic_direct.py +++ b/src/pythinker_code/auth/anthropic_direct.py @@ -11,6 +11,7 @@ from pythinker_code.auth import ANTHROPIC_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session ANTHROPIC_BASE_URL = "https://api.anthropic.com" @@ -88,7 +89,7 @@ def _apply_anthropic_config( config.default_model = ANTHROPIC_DEFAULT_MODEL_ALIAS else: config.default_model = fallback - config.default_thinking = False + apply_login_thinking_defaults(config, thinking=False, effort="off") def _model_by_id() -> dict[str, AnthropicModel]: diff --git a/src/pythinker_code/auth/browser_login_page.py b/src/pythinker_code/auth/browser_login_page.py index b39f8f96..08ddf8c2 100644 --- a/src/pythinker_code/auth/browser_login_page.py +++ b/src/pythinker_code/auth/browser_login_page.py @@ -5,6 +5,8 @@ from functools import lru_cache from pathlib import Path +from pythinker_code.utils.logging import logger + _PYTHINKER_BRAND_DIR = Path(__file__).resolve().parents[1] / "web" / "static" / "brand" _PYTHINKER_LOGO_PATH = _PYTHINKER_BRAND_DIR / "icon.svg" _PYTHINKER_FAVICON_PATH = _PYTHINKER_BRAND_DIR / "favicon.ico" @@ -14,7 +16,17 @@ # future caller with many distinct paths from leaking memory. @lru_cache(maxsize=16) def browser_login_asset_data_uri(path: Path, media_type: str) -> str: - encoded = base64.b64encode(path.read_bytes()).decode("utf-8") + # Fail soft: a missing/unreadable brand asset is cosmetic and must never break + # the OAuth callback page. Build outputs (web/static) are normally present, so + # log loudly and embed an empty source rather than raising mid-login. + try: + raw = path.read_bytes() + except OSError as exc: + logger.warning( + "Browser-login brand asset unavailable, rendering without it ({}): {}", path, exc + ) + return "" + encoded = base64.b64encode(raw).decode("utf-8") return f"data:{media_type};base64,{encoded}" diff --git a/src/pythinker_code/auth/deepseek.py b/src/pythinker_code/auth/deepseek.py index 7632b034..580ce09b 100644 --- a/src/pythinker_code/auth/deepseek.py +++ b/src/pythinker_code/auth/deepseek.py @@ -11,6 +11,7 @@ from pythinker_code.auth import DEEPSEEK_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" @@ -76,7 +77,7 @@ def _apply_deepseek_config( config.default_model = DEEPSEEK_DEFAULT_MODEL_ALIAS else: config.default_model = fallback - config.default_thinking = False + apply_login_thinking_defaults(config, thinking=False, effort="off") def _model_by_id() -> dict[str, DeepSeekModel]: diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index 30bb97d8..42e7ebff 100644 --- a/src/pythinker_code/auth/minimax.py +++ b/src/pythinker_code/auth/minimax.py @@ -11,6 +11,8 @@ from pythinker_code.auth import MINIMAX_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.llm import ModelCapability +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimax.io/anthropic" @@ -22,6 +24,7 @@ MINIMAX_DEFAULT_CONTEXT = 192_000 MINIMAX_TOKEN_PLAN_KEY_PREFIX = "sk-cp-" MINIMAX_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) +MINIMAX_NATIVE_THINKING_CAPABILITIES: frozenset[ModelCapability] = frozenset({"always_thinking"}) @dataclass(frozen=True, slots=True) @@ -31,6 +34,7 @@ class MiniMaxModel: display_name: str provider_key: str = MINIMAX_ANTHROPIC_PROVIDER_KEY max_context_size: int = MINIMAX_DEFAULT_CONTEXT + capabilities: frozenset[ModelCapability] = MINIMAX_NATIVE_THINKING_CAPABILITIES @property def alias(self) -> str: @@ -73,6 +77,7 @@ def _apply_minimax_config( provider=model.provider_key, model=model.model_id, max_context_size=model.max_context_size, + capabilities=set(model.capabilities) or None, display_name=model.display_name, ) @@ -87,7 +92,7 @@ def _apply_minimax_config( config.default_model = fallback elif config.default_model not in config.models: config.default_model = next(iter(config.models), "") - config.default_thinking = False + apply_login_thinking_defaults(config, thinking=False, effort="off") def _model_by_id() -> dict[str, MiniMaxModel]: @@ -182,6 +187,9 @@ def _parse_discovered_models(data: object) -> tuple[MiniMaxModel, ...]: display_name=display_name, provider_key=current.provider_key if current else MINIMAX_ANTHROPIC_PROVIDER_KEY, max_context_size=max_context_size, + capabilities=( + current.capabilities if current else MINIMAX_NATIVE_THINKING_CAPABILITIES + ), ) ) return tuple(result) @@ -260,6 +268,7 @@ def apply_minimax_models(config: Config, models: tuple[MiniMaxModel, ...]) -> bo provider=model.provider_key, model=model.model_id, max_context_size=model.max_context_size, + capabilities=set(model.capabilities) or None, display_name=model.display_name, ) changed = True @@ -273,6 +282,10 @@ def apply_minimax_models(config: Config, models: tuple[MiniMaxModel, ...]) -> bo if existing.max_context_size != model.max_context_size: existing.max_context_size = model.max_context_size changed = True + capabilities = set(model.capabilities) or None + if existing.capabilities != capabilities: + existing.capabilities = capabilities + changed = True if existing.display_name != model.display_name: existing.display_name = model.display_name changed = True diff --git a/src/pythinker_code/auth/oauth.py b/src/pythinker_code/auth/oauth.py index 07c8fff9..51f0b76f 100644 --- a/src/pythinker_code/auth/oauth.py +++ b/src/pythinker_code/auth/oauth.py @@ -39,6 +39,7 @@ ) from pythinker_code.constant import VERSION from pythinker_code.share import get_share_dir +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session from pythinker_code.utils.logging import logger @@ -601,7 +602,7 @@ def _apply_pythinker_code_config( ) config.default_model = managed_model_key(platform.id, selected_model.id) - config.default_thinking = thinking + apply_login_thinking_defaults(config, thinking=thinking, effort="high" if thinking else "off") if platform.search_url: config.services.pythinker_ai_search = PythinkerAISearchConfig( diff --git a/src/pythinker_code/auth/openai.py b/src/pythinker_code/auth/openai.py index e1b02efa..9a9ef7fe 100644 --- a/src/pythinker_code/auth/openai.py +++ b/src/pythinker_code/auth/openai.py @@ -33,6 +33,7 @@ ) from pythinker_code.config import Config, LLMModel, LLMProvider, OAuthRef, save_config from pythinker_code.constant import USER_AGENT +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session OPENAI_API_BASE_URL = "https://api.openai.com/v1" @@ -412,7 +413,7 @@ def _apply_openai_config( ) config.default_model = managed_model_key(platform_id, selected_model.id) - config.default_thinking = thinking + apply_login_thinking_defaults(config, thinking=thinking, effort="high" if thinking else "off") async def _request_device_code() -> DeviceCode: diff --git a/src/pythinker_code/auth/opencode_go.py b/src/pythinker_code/auth/opencode_go.py index 813f5282..7448ed85 100644 --- a/src/pythinker_code/auth/opencode_go.py +++ b/src/pythinker_code/auth/opencode_go.py @@ -11,6 +11,8 @@ from pythinker_code.auth import OPENCODE_GO_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.llm import ModelCapability +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session # OpenAI-compatible base: the OpenAI SDK appends "/chat/completions" (and the @@ -107,6 +109,7 @@ def _apply_opencode_go_config( provider=model.provider_key, model=model.model_id, max_context_size=model.max_context_size, + capabilities=_native_thinking_capabilities(model.model_id), display_name=model.display_name, ) @@ -115,7 +118,7 @@ def _apply_opencode_go_config( else: fallback = next((model.alias for model in models), next(iter(config.models), "")) config.default_model = fallback - config.default_thinking = False + apply_login_thinking_defaults(config, thinking=False, effort="off") def _model_by_id() -> dict[str, OpenCodeGoModel]: @@ -140,6 +143,14 @@ class _ModelsDevMeta: MODELS_DEV_ANTHROPIC_NPM = "@ai-sdk/anthropic" +def _native_thinking_capabilities(model_id: str) -> set[ModelCapability] | None: + """Model families whose reasoning is built in, not controlled by Pythinker.""" + normalized = model_id.lower().replace("_", "-") + if normalized.startswith(("glm-", "minimax-")): + return {"always_thinking"} + return None + + def _heuristic_provider_key(model_id: str) -> str: """Last-ditch shape guess when models.dev and the catalog are both silent. @@ -336,6 +347,7 @@ def apply_opencode_go_models(config: Config, models: tuple[OpenCodeGoModel, ...] provider=model.provider_key, model=model.model_id, max_context_size=model.max_context_size, + capabilities=_native_thinking_capabilities(model.model_id), display_name=model.display_name, ) changed = True @@ -349,6 +361,10 @@ def apply_opencode_go_models(config: Config, models: tuple[OpenCodeGoModel, ...] if existing.max_context_size != model.max_context_size: existing.max_context_size = model.max_context_size changed = True + capabilities = _native_thinking_capabilities(model.model_id) + if existing.capabilities != capabilities: + existing.capabilities = capabilities + changed = True if existing.display_name != model.display_name: existing.display_name = model.display_name changed = True diff --git a/src/pythinker_code/auth/openrouter.py b/src/pythinker_code/auth/openrouter.py index 24ac22ca..820f784f 100644 --- a/src/pythinker_code/auth/openrouter.py +++ b/src/pythinker_code/auth/openrouter.py @@ -11,6 +11,7 @@ from pythinker_code.auth import OPENROUTER_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" @@ -99,7 +100,7 @@ def _apply_openrouter_config( config.default_model = OPENROUTER_DEFAULT_MODEL_ALIAS else: config.default_model = fallback - config.default_thinking = False + apply_login_thinking_defaults(config, thinking=False, effort="off") def _model_by_id() -> dict[str, OpenRouterModel]: diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index 23bf1371..04645dcb 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -302,6 +302,14 @@ def pythinker( help="Enable thinking mode. Default: default thinking mode set in config file.", ), ] = None, + thinking_effort: Annotated[ + str | None, + typer.Option( + "--thinking-effort", + "--thinking-level", + help="Thinking effort level: off, minimal, low, medium, high, xhigh, or max.", + ), + ] = None, no_telemetry: Annotated[ bool, typer.Option( @@ -788,11 +796,23 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple scratch_exists=scratch_exists_before_start, ) + normalized_thinking_effort = None + if thinking_effort is not None: + from pythinker_code.thinking import normalize_thinking_effort + + normalized_thinking_effort = normalize_thinking_effort(thinking_effort) + if normalized_thinking_effort is None: + raise typer.BadParameter( + "expected one of: off, minimal, low, medium, high, xhigh, max", + param_hint="--thinking-effort", + ) + instance = await PythinkerCLI.create( session, config=config, model_name=model_name, thinking=thinking, + thinking_effort=normalized_thinking_effort, yolo=yolo, auto=auto, runtime_auto=ui == "print", diff --git a/src/pythinker_code/cli/review.py b/src/pythinker_code/cli/review.py index 5b5829b4..bf1c29c3 100644 --- a/src/pythinker_code/cli/review.py +++ b/src/pythinker_code/cli/review.py @@ -42,6 +42,7 @@ def build_active_llm(*, model_name: str | None = None) -> ReviewLLM | None: from pythinker_code.auth.oauth import OAuthManager from pythinker_code.config import LLMModel, LLMProvider, load_config from pythinker_code.llm import augment_provider_with_env_vars, create_llm, model_display_name + from pythinker_code.thinking import effective_config_thinking_effort config = load_config() selected = model_name or config.default_model @@ -56,6 +57,10 @@ def build_active_llm(*, model_name: str | None = None) -> ReviewLLM | None: provider, model, thinking=config.default_thinking, + thinking_effort=effective_config_thinking_effort( + config.default_thinking, + config.default_thinking_effort, + ), session_id=None, oauth=OAuthManager(config), ) diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 2bcd7529..718edfe1 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -17,6 +17,7 @@ field_validator, model_validator, ) +from pythinker_core.chat_provider import ThinkingEffort from tomlkit.exceptions import TOMLKitError from pythinker_code.exception import ConfigError @@ -352,6 +353,13 @@ class Config(BaseModel): ) default_model: str = Field(default="", description="Default model to use") default_thinking: bool = Field(default=False, description="Default thinking mode") + default_thinking_effort: ThinkingEffort | None = Field( + default=None, + description=( + "Default thinking effort level. When unset, default_thinking=true maps to high " + "and default_thinking=false maps to off for backward compatibility." + ), + ) agent_execution_profile: AgentExecutionProfile = Field( default="default", description=( @@ -360,12 +368,16 @@ class Config(BaseModel): ), ) default_yolo: bool = Field(default=False, description="Default yolo (auto-approve) mode") - ask_user_question_policy: Literal["always", "ask_except_auto", "never"] = Field( - default="ask_except_auto", - description=( - "Controls AskUserQuestion behavior: always ask, ask except in auto mode, " - "or never pause and let the agent use best judgment." - ), + ask_user_question_policy: Literal["always", "ask_except_auto", "never", "auto_deliberate"] = ( + Field( + default="ask_except_auto", + description=( + "Controls AskUserQuestion behavior: always ask, ask except in auto mode, " + "never pause (best judgment), or auto_deliberate (in auto mode, run an " + "advisor-assisted self-decision instead of dismissing, and bounce " + "destructive actions once for deliberation)." + ), + ) ) skip_auto_prompt_injection: bool = Field( default=False, @@ -469,6 +481,11 @@ def _apply_agent_execution_profile(self) -> None: if "default_yolo" not in fields_set: self.default_yolo = True if "ask_user_question_policy" not in fields_set: + # NOTE: spec §6 #1 proposes shifting this to "auto_deliberate", but + # that is only safe once the profile also enables auto mode (so + # AskUserQuestion's Entry A self-decision engages and never blocks a + # headless run waiting for an absent user). Until that auto-from- + # profile path exists, keep the robust "never" (always dismiss). self.ask_user_question_policy = "never" elif profile == "plan_only": if "default_plan_mode" not in fields_set: diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 47041177..d738149b 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -7,9 +7,15 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast, get_args -from pythinker_core.chat_provider import ChatProvider +from pythinker_core.chat_provider import ChatProvider, ThinkingEffort from pythinker_code.constant import USER_AGENT +from pythinker_code.thinking import ( + DEFAULT_THINKING_EFFORT, + bool_to_thinking_effort, + normalize_thinking_effort, + thinking_effort_enabled, +) from pythinker_code.utils.logging import logger if TYPE_CHECKING: @@ -42,6 +48,7 @@ class LLM: model_config: LLMModel | None = None provider_config: LLMProvider | None = None thinking: bool | None = None + thinking_effort: ThinkingEffort | None = None @property def model_name(self) -> str: @@ -157,6 +164,7 @@ def create_llm( model: LLMModel, *, thinking: bool | None = None, + thinking_effort: ThinkingEffort | None = None, session_id: str | None = None, oauth: OAuthManager | None = None, ) -> LLM | None: @@ -317,22 +325,38 @@ def create_llm( capabilities = derive_model_capabilities(model) - # Apply thinking if specified or if model always requires thinking - thinking_on = "always_thinking" in capabilities or ( - thinking is True and "thinking" in capabilities + requested_effort = ( + normalize_thinking_effort(thinking_effort) + if thinking_effort is not None + else bool_to_thinking_effort(thinking) ) + if thinking_effort is not None and requested_effort is None: + raise ValueError(f"Invalid thinking effort: {thinking_effort!r}") + + supports_thinking = "thinking" in capabilities + if "always_thinking" in capabilities: + # Always-thinking models cannot be disabled. Preserve an explicit + # non-off effort; otherwise keep the legacy high-effort default. + effective_effort = ( + requested_effort + if requested_effort is not None and requested_effort != "off" + else DEFAULT_THINKING_EFFORT + ) + elif supports_thinking: + effective_effort = requested_effort + else: + # Match pi-main's clamp-to-model behavior: non-reasoning models have + # only the off level, so explicit non-off requests become off instead of + # being recorded as active but ignored by the provider. + effective_effort = "off" if requested_effort is not None else None + + thinking_on = thinking_effort_enabled(effective_effort) is_kimi_openai_legacy = provider.type == "openai_legacy" and _is_kimi_k2_model(model.model) - if thinking_on and not is_kimi_openai_legacy: - chat_provider = chat_provider.with_thinking("high") - elif thinking is False and "thinking" in capabilities and not is_kimi_openai_legacy: - # Only explicitly send `reasoning_effort: null` for models that actually - # support reasoning. For models without the thinking capability, omit - # the field entirely — some providers (e.g., Alibaba via OpenAI-compat) - # reject explicit nulls with `'reasoning_effort' must be an object ... - # or a String`. - chat_provider = chat_provider.with_thinking("off") - # If thinking is None, or thinking is False on a non-reasoning model, leave - # the chat provider's default reasoning_effort (Omit) untouched. + if effective_effort is not None and supports_thinking and not is_kimi_openai_legacy: + # Only explicitly send thinking controls for models that advertise + # reasoning. Some OpenAI-compatible non-reasoning models reject even a + # null reasoning_effort field. + chat_provider = chat_provider.with_thinking(effective_effort) # Kimi K2.5/K2.6 use an OpenAI-compatible API but their thinking toggle is # the provider-specific `thinking.type` body field rather than OpenAI's @@ -340,12 +364,10 @@ def create_llm( # config says thinking is off we must send the explicit Kimi switch; # otherwise multi-step tool calls can still enter thinking mode and require # `reasoning_content` on replayed tool-call turns. - if is_kimi_openai_legacy: - thinking_type = "enabled" if thinking_on else "disabled" if thinking is False else None - if thinking_type is not None: - chat_provider = cast(Any, chat_provider).with_generation_kwargs( - extra_body={"thinking": {"type": thinking_type}} - ) + if is_kimi_openai_legacy and effective_effort is not None: + chat_provider = cast(Any, chat_provider).with_generation_kwargs( + extra_body={"thinking": {"type": "enabled" if thinking_on else "disabled"}} + ) # Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when # the model is actually in thinking mode; otherwise the API would see a @@ -364,7 +386,10 @@ def create_llm( capabilities=capabilities, model_config=model, provider_config=provider, - thinking=thinking, + thinking=thinking_effort_enabled(effective_effort) + if effective_effort is not None + else thinking, + thinking_effort=effective_effort, ) @@ -376,6 +401,7 @@ def clone_llm_with_model_alias( session_id: str, oauth: OAuthManager | None, thinking: bool | None = None, + thinking_effort: ThinkingEffort | None = None, ) -> LLM | None: if model_alias is None: return llm @@ -383,16 +409,19 @@ def clone_llm_with_model_alias( raise KeyError(f"Unknown model alias: {model_alias}") model = config.models[model_alias] provider = config.providers[model.provider] - if thinking is None and llm is not None: - thinking = llm.thinking - if thinking is None and llm is not None: + if thinking_effort is None and thinking is None and llm is not None: + thinking_effort = llm.thinking_effort + if thinking_effort is None and thinking is None and llm is not None: effort = getattr(llm.chat_provider, "thinking_effort", None) if effort is not None: - thinking = effort != "off" + thinking_effort = effort + if thinking_effort is None and thinking is None and llm is not None: + thinking = llm.thinking return create_llm( provider, model, thinking=thinking, + thinking_effort=thinking_effort, session_id=session_id, oauth=oauth, ) diff --git a/src/pythinker_code/session_recap.py b/src/pythinker_code/session_recap.py index f73b88c6..9fa9c74d 100644 --- a/src/pythinker_code/session_recap.py +++ b/src/pythinker_code/session_recap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from collections import Counter from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -162,12 +163,14 @@ def format_recap(items: list[SessionRecapItem], recap_range: RecapRange) -> str: total_minutes = sum(item.duration_minutes for item in items) total_turns = sum(item.turn_count for item in items) + if _is_light_day(items, total_minutes, total_turns): + return _format_light_recap(title, items[0], total_minutes, total_turns) lines = [title, "", "**What you worked on:**"] for item in items: duration = _format_duration(item.duration_minutes) tools = _format_tool_counts(item.tool_counts) - first = shorten(item.first_user_message, width=150) - line = f"- **{item.title}** ({duration}, {item.turn_count} turns) — {first}" + outcome = shorten(_session_outcome(item), width=150) + line = f"- **{item.title}** ({duration}, {item.turn_count} turns) — {outcome}" if tools: line += f" Tools: {tools}." if item.files_modified: @@ -188,16 +191,57 @@ def format_recap(items: list[SessionRecapItem], recap_range: RecapRange) -> str: return "\n".join(lines) +_LIGHT_DAY_MAX_MINUTES = 30 +_LIGHT_DAY_MAX_TURNS = 4 + + +def _is_light_day(items: list[SessionRecapItem], total_minutes: int, total_turns: int) -> bool: + """A single short session that changed nothing reads as a light day.""" + return ( + len(items) == 1 + and total_turns < _LIGHT_DAY_MAX_TURNS + and total_minutes < _LIGHT_DAY_MAX_MINUTES + and not any(item.files_modified for item in items) + ) + + +def _format_light_recap(title: str, item: SessionRecapItem, minutes: int, turns: int) -> str: + lines = [ + title, + "", + ( + f"Light day — one session, {turns} turn{'s' if turns != 1 else ''}, " + f"{_format_duration(minutes)}." + ), + ] + summary = _session_outcome(item) + if summary: + lines.extend(["", shorten(summary, width=220)]) + return "\n".join(lines) + + def build_turn_recap_line( - *, request: str, assistant_text: str = "", step_count: int | None = None + *, + request: str, + assistant_text: str = "", + step_count: int | None = None, + files_changed: int = 0, ) -> str | None: - source = _first_sentence(assistant_text) or request.strip() + assistant_source = _recap_source_text(assistant_text) + request_source = _recap_source_text(request) + source = ( + _outcome_sentence(assistant_source) or _outcome_sentence(request_source) or request_source + ) if not source: return None - summary = shorten(" ".join(source.split()), width=180) + summary = shorten(source, width=180) + deltas: list[str] = [] + if files_changed > 0: + deltas.append(f"{files_changed} file{'s' if files_changed != 1 else ''} changed") if step_count is not None and step_count > 0: - summary += f" ({step_count} step{'s' if step_count != 1 else ''})" - return f"※ recap: {summary} (disable recaps in /settings)" + deltas.append(f"{step_count} step{'s' if step_count != 1 else ''}") + suffix = f" · {' · '.join(deltas)}" if deltas else "" + return f"※ recap: {summary}{suffix} (disable recaps in /settings)" def _last_substantive_thread(items: list[SessionRecapItem]) -> str: @@ -207,17 +251,119 @@ def _last_substantive_thread(items: list[SessionRecapItem]) -> str: return "" +_MIN_RECAP_SENTENCE_CHARS = 16 +_FENCE_START_RE = re.compile(r"^ {0,3}(```+|~~~+)") + + +def _recap_source_text(text: str) -> str: + """Return one-line recap input with machine-readable blocks removed.""" + without_fences = _strip_fenced_blocks(text) + without_ticks = without_fences.replace("`", "") + return " ".join(without_ticks.split()) + + +def _strip_fenced_blocks(text: str) -> str: + lines: list[str] = [] + in_fence = False + fence_marker = "" + for line in text.splitlines(): + stripped = line.lstrip() + match = _FENCE_START_RE.match(line) + if in_fence: + if stripped.startswith(fence_marker): + in_fence = False + fence_marker = "" + continue + if match is not None: + in_fence = True + fence_marker = match.group(1)[0] * len(match.group(1)) + continue + lines.append(line) + return "\n".join(lines) + + def _first_sentence(text: str) -> str: cleaned = " ".join(text.split()) if not cleaned: return "" for sep in (". ", "! ", "? "): idx = cleaned.find(sep) - if idx >= 40: + if idx >= _MIN_RECAP_SENTENCE_CHARS: return cleaned[: idx + 1] return cleaned +# A turn's text opens with intent and ends with a summary. These openers mark +# intent/offer sentences that describe what *will* happen, not what was done. +_SKIP_SENTENCE_PREFIXES = ( + "i'll", + "i will", + "let me", + "let's", + "now i'll", + "now let", + "i'm going to", + "i am going to", + "i'm going", + "to start", + "starting by", + "first, i", + "next, i", + "want me", + "let me know", + "shall i", + "should i", + "do you want", + "would you like", +) +# A single token longer than this is almost always a path/URL/hash, not prose. +_MAX_RECAP_TOKEN_CHARS = 30 +_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") + + +def _iter_sentences(text: str) -> list[str]: + cleaned = " ".join(text.split()) + if not cleaned: + return [] + return [part for part in _SENTENCE_SPLIT_RE.split(cleaned) if part.strip()] + + +def _is_outcome_sentence(sentence: str) -> bool: + """True when the sentence reads like a result rather than intent or noise.""" + candidate = sentence.strip() + if len(candidate) < _MIN_RECAP_SENTENCE_CHARS: + return False + if candidate.endswith("?"): + return False + # Models emit a typographic apostrophe (U+2019); normalize so intent + # openers like "I'll" / "I’ll" are skipped identically. + lowered = candidate.lower().replace("’", "'") + if lowered.startswith(_SKIP_SENTENCE_PREFIXES): + return False + return not any(len(token) > _MAX_RECAP_TOKEN_CHARS for token in candidate.split()) + + +def _outcome_sentence(text: str) -> str: + """Return the closing outcome sentence, skipping intent/offer/noise lines. + + Falls back to the first sentence when nothing reads like an outcome, so a + terse or interrupted turn still produces a line. + """ + outcomes = [s for s in _iter_sentences(text) if _is_outcome_sentence(s)] + if outcomes: + return outcomes[-1] + return _first_sentence(text) + + +def _session_outcome(item: SessionRecapItem) -> str: + """What the session accomplished — its closing summary, not the first ask.""" + combined = " ".join(item.assistant_snippets[-3:]) + sentence = _outcome_sentence(combined) + if sentence and _is_outcome_sentence(sentence): + return sentence + return item.first_user_message + + def _format_duration(minutes: int) -> str: if minutes < 1: return "<1 min" diff --git a/src/pythinker_code/soul/__init__.py b/src/pythinker_code/soul/__init__.py index 897c37f4..a980f0d0 100644 --- a/src/pythinker_code/soul/__init__.py +++ b/src/pythinker_code/soul/__init__.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from pythinker_core.chat_provider import ThinkingEffort + from pythinker_code.hooks.engine import HookEngine from pythinker_code.utils.aioqueue import QueueShutDown from pythinker_code.utils.logging import logger @@ -125,6 +127,11 @@ def thinking(self) -> bool | None: """ ... + @property + def thinking_effort(self) -> ThinkingEffort | None: + """Current thinking effort level, if known.""" + ... + @property def status(self) -> StatusSnapshot: """The current status of the soul. The returned value is immutable.""" diff --git a/src/pythinker_code/soul/agent.py b/src/pythinker_code/soul/agent.py index 64200b73..d5dfe4d2 100644 --- a/src/pythinker_code/soul/agent.py +++ b/src/pythinker_code/soul/agent.py @@ -300,6 +300,7 @@ def _on_approval_change() -> None: auto=session.state.approval.auto, runtime_auto=runtime_auto, safe_mode=effective_safe_mode, + auto_deliberate=config.ask_user_question_policy == "auto_deliberate", auto_approve_actions=saved_actions, on_change=_on_approval_change, ) diff --git a/src/pythinker_code/soul/approval.py b/src/pythinker_code/soul/approval.py index 55b56d8f..6b6a572c 100644 --- a/src/pythinker_code/soul/approval.py +++ b/src/pythinker_code/soul/approval.py @@ -1,9 +1,12 @@ from __future__ import annotations +import json import uuid from collections.abc import Callable from typing import Literal +from pythinker_core.utils.typing import JsonType + from pythinker_code.approval_runtime import ( ApprovalCancelledError, ApprovalRuntime, @@ -16,24 +19,40 @@ ) from pythinker_code.tools.utils import ToolRejectedError from pythinker_code.utils.logging import logger -from pythinker_code.wire.types import DisplayBlock +from pythinker_code.wire.types import DisplayBlock, ToolCall type Response = Literal["approve", "approve_for_session", "reject"] +_DELIBERATION_FEEDBACK = ( + "No user is present and this action is irreversible ({reason}). Before re-issuing: " + "enumerate the realistic alternatives, weigh them against the current task, and commit " + "to the best one. If this exact action is still right, re-issue it and it will run." +) + class ApprovalResult: """Result of an approval request. Behaves as bool for backward compatibility.""" - __slots__ = ("approved", "feedback") + __slots__ = ("approved", "feedback", "deliberation") - def __init__(self, approved: bool, feedback: str = ""): + def __init__(self, approved: bool, feedback: str = "", deliberation: bool = False): self.approved = approved self.feedback = feedback + self.deliberation = deliberation + """True when the bounce is an auto-mode deliberation prompt, not a user rejection.""" def __bool__(self) -> bool: return self.approved def rejection_error(self) -> ToolRejectedError: + if self.deliberation and self.feedback: + # Auto-mode deliberation: no user is present, so do not frame it as a + # user rejection — the feedback itself is the instruction to deliberate. + return ToolRejectedError( + message=self.feedback, + brief="Deliberate before retrying", + has_feedback=True, + ) if self.feedback: return ToolRejectedError( message=(f"The tool call is rejected by the user. User feedback: {self.feedback}"), @@ -62,6 +81,7 @@ def __init__( auto: bool = False, runtime_auto: bool = False, safe_mode: bool = False, + auto_deliberate: bool = False, auto_approve_actions: set[str] | None = None, on_change: Callable[[], None] | None = None, ): @@ -75,10 +95,20 @@ def __init__( """Invocation-only auto flag, e.g. ``--auto`` or ``--print``. Not persisted.""" self.safe_mode = safe_mode """When true, all auto-approval paths are suppressed.""" + self.auto_deliberate = auto_deliberate + """When true, destructive auto-approved actions must deliberate once first. + + Wired in ``Runtime.create`` from ``config.ask_user_question_policy == + "auto_deliberate"``. Gates *ahead* of yolo/auto: an irreversible action + (``rm -rf``, ``git push --force``, ...) is bounced back once for the agent + to weigh alternatives before it runs. + """ self.auto_approve_actions: set[str] = auto_approve_actions or set() """Set of action names that should automatically be approved.""" self.approved_orchestration_fingerprints: set[str] = set() """RunAgents orchestration shapes approved for this in-memory session.""" + self.deliberated_fingerprints: set[str] = set() + """Destructive (tool, command) shapes already bounced once; the re-issue runs.""" self._on_change = on_change def notify_change(self) -> None: @@ -171,6 +201,60 @@ def is_orchestration_approved(self, fingerprint: str) -> bool: def approve_orchestration(self, fingerprint: str) -> None: self._state.approved_orchestration_fingerprints.add(fingerprint) + @staticmethod + def _tool_arguments(tool_call: ToolCall) -> dict[str, JsonType] | None: + """Parse a tool call's JSON arguments into a dict, else ``None``.""" + try: + args: JsonType = json.loads(tool_call.function.arguments or "{}") + except (ValueError, TypeError): + return None + return args if isinstance(args, dict) else None + + @staticmethod + def _deliberation_fingerprint(tool_name: str, arguments: dict[str, JsonType]) -> str: + """Stable, tool-agnostic identity for a destructive call (name + sorted args).""" + encoded = json.dumps(arguments, sort_keys=True, separators=(",", ":")) + return f"{tool_name}::{encoded}" + + def deliberation_gate(self, tool_call: ToolCall) -> str | None: + """Reason a destructive auto-approved action must deliberate once, else ``None``. + + Fires only when ``auto_deliberate`` is on, the action would otherwise be + auto-approved (auto *or* yolo — so it gates ahead of the yolo bypass), and the + tool call is destructive per the tool-agnostic classifier in ``permission`` + (today only ``Shell``; other destructive tools register their classifier there). + One-shot: the first occurrence is bounced for the agent to weigh alternatives; + the identical re-issue is let through once, so a deliberated ``rm -rf`` runs + without being permanently whitelisted. + """ + if not self._state.auto_deliberate: + return None + if not self.is_auto_approve(): + return None + arguments = self._tool_arguments(tool_call) + if arguments is None: + return None + from pythinker_code.soul.permission import tool_destructive_reason + + reason = tool_destructive_reason(tool_call.function.name, arguments) + if reason is None: + return None + fingerprint = self._deliberation_fingerprint(tool_call.function.name, arguments) + # NOTE (known limitation, tracked): the one-shot is keyed only by the + # (tool, command) fingerprint, not by an assistant-turn boundary. If a + # model emits two byte-identical destructive calls within the SAME + # response (no intervening deliberation turn), the second consumes the + # one-shot and runs. Distinguishing that from a genuine re-issue needs a + # turn/generation signal not plumbed into the approval layer; spec §6 #2 + # treats the one-shot as an open decision. Only reachable when a user has + # opted into the auto_deliberate policy (not a default), and requires the + # model to emit identical destructive calls in one response. + if fingerprint in self._state.deliberated_fingerprints: + self._state.deliberated_fingerprints.discard(fingerprint) # consume one-shot + return None + self._state.deliberated_fingerprints.add(fingerprint) + return reason + async def request( self, sender: str, @@ -205,6 +289,21 @@ async def request( action=action, description=description, ) + # Gate ahead of the auto/yolo auto-approve: an irreversible action under + # auto_deliberate is bounced once so the agent weighs alternatives first. + if (reason := self.deliberation_gate(tool_call)) is not None: + from pythinker_code.telemetry import track + + track( + "tool_deliberation", + tool_name=tool_call.function.name, + approval_mode="auto" if self.is_auto() else "yolo", + ) + return ApprovalResult( + approved=False, + feedback=_DELIBERATION_FEEDBACK.format(reason=reason), + deliberation=True, + ) if self.is_auto_approve(): from pythinker_code.telemetry import track diff --git a/src/pythinker_code/soul/deliberation.py b/src/pythinker_code/soul/deliberation.py new file mode 100644 index 00000000..76da120a --- /dev/null +++ b/src/pythinker_code/soul/deliberation.py @@ -0,0 +1,92 @@ +"""Blind-first advisor for auto-mode AskUserQuestion deliberation (Entry A). + +A single tool-less LLM call that ranks the model's own enumerated options +WITHOUT seeing which one the model favors. Modeled on ``soul/btw.py``. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import pythinker_core +from pythinker_core.message import Message, TextPart +from pythinker_core.tooling.empty import EmptyToolset + +from pythinker_code.utils.logging import logger + +if TYPE_CHECKING: + from pythinker_core.chat_provider import StreamedMessagePart + + from pythinker_code.soul.pythinkersoul import PythinkerSoul + from pythinker_code.wire.types import QuestionItem + +_RECOMMENDED_RE = re.compile(r"\s*\(\s*recommended\s*\)\s*$", re.IGNORECASE) + +_ADVISOR_SYSTEM_PROMPT = ( + "You are an independent decision advisor for an autonomous coding agent that has " + "no human available. You are shown a decision and its candidate options, but NOT " + "which option the agent prefers. Rank the options from best to worst for the stated " + "task, each with a one-line rationale grounded in the trade-offs. Be decisive and " + "concise. You are advising, not deciding — the agent makes the final call." +) + + +def _strip_recommended(label: str) -> str: + return _RECOMMENDED_RE.sub("", label).strip() + + +def _format_questions_for_advisor(questions: Sequence[QuestionItem]) -> str: + blocks: list[str] = [] + for qi, q in enumerate(questions, 1): + lines = [f"Decision {qi}: {q.question}"] + for oi, opt in enumerate(q.options, 1): + label = _strip_recommended(opt.label) + desc = f" — {opt.description}" if opt.description else "" + lines.append(f" {oi}. {label}{desc}") + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + + +async def blind_advisor_verdict( + soul: PythinkerSoul, + questions: Sequence[QuestionItem], +) -> str | None: + """Return the advisor's ranked verdict, or ``None`` if unavailable. + + Never raises: any failure (no LLM, provider error) returns ``None`` so the + caller falls back to a plain self-decision prompt rather than blocking. + """ + runtime = soul._runtime # pyright: ignore[reportPrivateUsage] + if runtime.llm is None: + return None + try: + chat_provider = runtime.llm.chat_provider + prompt = ( + "Independently rank the options for the following decision(s). " + "You do not know which option is favored.\n\n" + f"{_format_questions_for_advisor(questions)}" + ) + history = [Message(role="user", content=prompt)] + chunks: list[str] = [] + + def _on_part(part: StreamedMessagePart) -> None: + if isinstance(part, TextPart) and part.text: + chunks.append(part.text) + + await pythinker_core.step( + chat_provider, + _ADVISOR_SYSTEM_PROMPT, + EmptyToolset(), + history, + on_message_part=_on_part, + ) + verdict = "".join(chunks).strip() + return verdict or None + except Exception as exc: # noqa: BLE001 — advisor is best-effort, never blocks + from pythinker_code.telemetry.errors import report_handled_error + + report_handled_error(exc, site="soul.deliberation.advisor") + logger.warning("Blind advisor failed: {error}", error=exc) + return None diff --git a/src/pythinker_code/soul/dynamic_injections/auto_mode.py b/src/pythinker_code/soul/dynamic_injections/auto_mode.py index 396b1c5b..b9b3e2f8 100644 --- a/src/pythinker_code/soul/dynamic_injections/auto_mode.py +++ b/src/pythinker_code/soul/dynamic_injections/auto_mode.py @@ -25,6 +25,21 @@ "decisions to a human." ) +_AUTO_PROMPT_DELIBERATE = ( + "You are running in auto mode. No user is present to answer " + "questions or approve actions. Most tool calls are auto-approved by " + "the harness; irreversible ones may be bounced once for deliberation.\n" + "- At a genuine, consequential, hard-to-reverse fork, you MAY call " + "AskUserQuestion: it triggers an advisor-assisted self-decision (you " + "still decide). Do NOT ask routine confirmations or progress " + "check-ins — proceed instantly on trivial, reversible choices.\n" + "- You CAN use EnterPlanMode / ExitPlanMode normally. They will be " + "auto-approved. Planning still helps you think before acting; use " + "it for non-trivial tasks, then exit and execute.\n" + "- Finish the user's request end-to-end in this run. Do not defer " + "decisions to a human." +) + AUTO_DISABLED_REMINDER = ( "Auto mode is now disabled. The user is back at the terminal and CAN answer " "AskUserQuestion.\n" @@ -57,7 +72,12 @@ async def get_injections( if self._injected: return [] self._injected = True - return [DynamicInjection(type=_AUTO_INJECTION_TYPE, content=_AUTO_PROMPT)] + # Under the auto_deliberate policy AskUserQuestion self-decides (advisor- + # assisted) instead of being dismissed, so invite it at consequential + # forks; every other policy keeps the "do not call it" guidance. + deliberate = soul.runtime.config.ask_user_question_policy == "auto_deliberate" + content = _AUTO_PROMPT_DELIBERATE if deliberate else _AUTO_PROMPT + return [DynamicInjection(type=_AUTO_INJECTION_TYPE, content=content)] async def on_context_compacted(self) -> None: # Compaction rewrites history; the prior auto-mode reminder may have diff --git a/src/pythinker_code/soul/permission.py b/src/pythinker_code/soul/permission.py index 6d22b1b4..e9b1ac61 100644 --- a/src/pythinker_code/soul/permission.py +++ b/src/pythinker_code/soul/permission.py @@ -497,6 +497,20 @@ def shell_destructive_reason(command: str) -> str | None: return None +def tool_destructive_reason(tool_name: str, arguments: dict[str, Any]) -> str | None: + """Reason a tool call is irreversibly destructive (warrants deliberation), else ``None``. + + Tool-agnostic dispatch point for the auto-deliberation gate. Today only ``Shell`` + is classified; a future destructive tool registers its own argument classifier + here instead of the gate hard-coding a single tool name. + """ + if tool_name == "Shell": + command = arguments.get("command") + if isinstance(command, str): + return shell_destructive_reason(command) + return None + + def _segment_destructive_reason(tokens: list[str]) -> str | None: if not tokens: return None diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 41c01b29..5386610c 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -20,6 +20,7 @@ APIStatusError, APITimeoutError, RetryableChatProvider, + ThinkingEffort, ) from pythinker_core.message import Message, ToolCall from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter @@ -32,7 +33,7 @@ ) from pythinker_code.background import build_active_task_snapshot from pythinker_code.hooks.engine import HookEngine -from pythinker_code.llm import ModelCapability +from pythinker_code.llm import ModelCapability, create_llm from pythinker_code.notifications import ( NotificationView, build_notification_message, @@ -85,6 +86,13 @@ ) from pythinker_code.soul.slash import registry as soul_slash_registry from pythinker_code.soul.toolset import PythinkerToolset +from pythinker_code.thinking import ( + available_thinking_levels, + bool_to_thinking_effort, + clamp_thinking_effort, + next_thinking_level, + thinking_effort_enabled, +) from pythinker_code.tools.dmail import NAME as SendDMail_NAME from pythinker_code.tools.utils import ToolRejectedError from pythinker_code.utils.logging import logger @@ -97,6 +105,7 @@ ContentPart, MCPLoadingBegin, MCPLoadingEnd, + QuestionItem, StatusUpdate, SteerInput, StepBegin, @@ -543,6 +552,13 @@ def path_getter() -> Path | None: policy=self._runtime.config.ask_user_question_policy, ) + async def _advise(questions: list[QuestionItem]) -> str | None: + from pythinker_code.soul.deliberation import blind_advisor_verdict + + return await blind_advisor_verdict(self, questions) + + ask_tool.bind_deliberation(_advise) + def _ensure_plan_session_id(self) -> None: """Allocate a stable plan session ID on first activation.""" if self._plan_session_id is None: @@ -636,14 +652,88 @@ def consume_pending_plan_activation_injection(self) -> bool: return True @property - def thinking(self) -> bool | None: - """Whether thinking mode is enabled.""" + def thinking_effort(self) -> ThinkingEffort | None: + """Current thinking effort level, if known.""" if self._runtime.llm is None: return None + if self._runtime.llm.thinking_effort is not None: + return self._runtime.llm.thinking_effort if thinking_effort := self._runtime.llm.chat_provider.thinking_effort: - return thinking_effort != "off" + return thinking_effort + return bool_to_thinking_effort(self._runtime.llm.thinking) + + @property + def thinking(self) -> bool | None: + """Whether thinking mode is enabled.""" + effort = self.thinking_effort + if effort is not None: + return thinking_effort_enabled(effort) return None + 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) + + def set_thinking_effort_from_manual(self, effort: ThinkingEffort) -> ThinkingEffort | None: + """Apply a user-selected thinking level to the live runtime. + + Returns the effective/clamped level, or ``None`` when no LLM/model is + active. Best-effort persistence mirrors pi-main's settings update, but + a config write failure must not prevent the current session from using + the new level. + """ + if self._runtime.llm is None or self._runtime.llm.model_config is None: + return None + model = self._runtime.llm.model_config + provider = self._runtime.config.providers.get(model.provider) + if provider is None: + provider = self._runtime.llm.provider_config + if provider is None: + return None + + levels = self.available_thinking_efforts() + effective_effort = clamp_thinking_effort(effort, levels) + new_llm = create_llm( + provider, + model, + thinking=thinking_effort_enabled(effective_effort), + thinking_effort=effective_effort, + session_id=self._runtime.session.id, + oauth=self._runtime.oauth, + ) + if new_llm is None: + return None + self._runtime.llm = new_llm + self._runtime.config.default_thinking = thinking_effort_enabled(effective_effort) + self._runtime.config.default_thinking_effort = effective_effort + + config_file = self._runtime.config.source_file + if config_file is not None: + from pythinker_code.config import load_config, save_config + from pythinker_code.exception import ConfigError + + try: + config_for_save = load_config(config_file) + config_for_save.default_thinking = thinking_effort_enabled(effective_effort) + config_for_save.default_thinking_effort = effective_effort + save_config(config_for_save, config_file) + except (ConfigError, OSError) as exc: + logger.warning( + "Failed to persist thinking effort change: {error}", + error=exc, + ) + return effective_effort + + def cycle_thinking_effort_from_manual(self) -> ThinkingEffort | None: + """Cycle to the next thinking level for the current model.""" + levels = self.available_thinking_efforts() + if levels == ("off",): + return None + current = self.thinking_effort or "off" + return self.set_thinking_effort_from_manual(next_thinking_level(current, levels)) + @property def status(self) -> StatusSnapshot: token_count = self._context.token_count diff --git a/src/pythinker_code/subagents/builder.py b/src/pythinker_code/subagents/builder.py index 62cf876b..9c92f996 100644 --- a/src/pythinker_code/subagents/builder.py +++ b/src/pythinker_code/subagents/builder.py @@ -24,6 +24,7 @@ async def build_builtin_instance( session_id=self._root_runtime.session.id, oauth=self._root_runtime.oauth, thinking=launch_spec.thinking, + thinking_effort=launch_spec.thinking_effort, ) runtime = self._root_runtime.copy_for_subagent( agent_id=agent_id, diff --git a/src/pythinker_code/subagents/models.py b/src/pythinker_code/subagents/models.py index 47487c5e..74c90cc0 100644 --- a/src/pythinker_code/subagents/models.py +++ b/src/pythinker_code/subagents/models.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Literal +from pythinker_core.chat_provider import ThinkingEffort + type ToolPolicyMode = Literal["inherit", "allowlist"] type SubagentStatus = Literal[ "idle", @@ -40,6 +42,7 @@ class AgentLaunchSpec: model_override: str | None effective_model: str | None thinking: bool | None = None + thinking_effort: ThinkingEffort | None = None variant: str | None = None parent_agent_id: str | None = None created_at: float = field(default_factory=time.time) diff --git a/src/pythinker_code/subagents/runner.py b/src/pythinker_code/subagents/runner.py index ed3e72d3..86a25870 100644 --- a/src/pythinker_code/subagents/runner.py +++ b/src/pythinker_code/subagents/runner.py @@ -412,6 +412,9 @@ async def _prepare_instance(self, req: ForegroundRunRequest) -> PreparedInstance model_override=req.model, effective_model=req.model or type_def.default_model, thinking=self._runtime.llm.thinking if self._runtime.llm is not None else None, + thinking_effort=( + self._runtime.llm.thinking_effort if self._runtime.llm is not None else None + ), parent_agent_id=self._runtime.subagent_id, ), ) diff --git a/src/pythinker_code/subagents/store.py b/src/pythinker_code/subagents/store.py index b9d0e87f..3eba4772 100644 --- a/src/pythinker_code/subagents/store.py +++ b/src/pythinker_code/subagents/store.py @@ -7,6 +7,7 @@ from typing import Any, cast from pydantic import BaseModel, ValidationError +from pythinker_core.chat_provider import ThinkingEffort from pythinker_code.session import Session from pythinker_code.subagents.models import AgentInstanceRecord, AgentLaunchSpec, SubagentStatus @@ -20,6 +21,7 @@ class _AgentLaunchSpecPayload(BaseModel): model_override: str | None effective_model: str | None thinking: bool | None = None + thinking_effort: ThinkingEffort | None = None variant: str | None = None parent_agent_id: str | None = None created_at: float @@ -60,6 +62,7 @@ def _record_from_dict(data: dict[str, Any]) -> AgentInstanceRecord: model_override=payload.launch_spec.model_override, effective_model=payload.launch_spec.effective_model, thinking=payload.launch_spec.thinking, + thinking_effort=payload.launch_spec.thinking_effort, variant=payload.launch_spec.variant, parent_agent_id=payload.launch_spec.parent_agent_id, created_at=payload.launch_spec.created_at, diff --git a/src/pythinker_code/thinking.py b/src/pythinker_code/thinking.py new file mode 100644 index 00000000..5b8f2677 --- /dev/null +++ b/src/pythinker_code/thinking.py @@ -0,0 +1,156 @@ +"""Shared reasoning/thinking effort helpers. + +The UI exposes the same provider-neutral effort dial as pi-main. Provider +adapters may map or clamp unsupported levels internally, but callers should +preserve the user's requested level in config/session state and pass it through +to ``ChatProvider.with_thinking`` when the selected model advertises reasoning +support. +""" + +from __future__ import annotations + +from collections.abc import Collection, Sequence +from typing import TYPE_CHECKING + +from pythinker_core.chat_provider import ThinkingEffort + +if TYPE_CHECKING: + from pythinker_code.config import Config + +THINKING_LEVELS: tuple[ThinkingEffort, ...] = ( + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +) +"""Provider-neutral user-facing effort order used by /thinking and Shift+Tab.""" + +EXTENDED_THINKING_LEVELS: tuple[ThinkingEffort, ...] = (*THINKING_LEVELS, "max") +"""All accepted effort values, including provider-specific aliases.""" + +DEFAULT_THINKING_EFFORT: ThinkingEffort = "high" + +LEVEL_DESCRIPTIONS: dict[ThinkingEffort, str] = { + "off": "No reasoning", + "minimal": "Very brief reasoning (~1k tokens)", + "low": "Light reasoning (~2k tokens)", + "medium": "Moderate reasoning (~8k tokens)", + "high": "Deep reasoning (~16k tokens)", + "xhigh": "Maximum reasoning (~32k tokens)", + "max": "Provider maximum reasoning", +} + + +def bool_to_thinking_effort(thinking: bool | None) -> ThinkingEffort | None: + """Map legacy boolean thinking state to an effort level.""" + if thinking is None: + return None + return DEFAULT_THINKING_EFFORT if thinking else "off" + + +def thinking_effort_enabled(effort: ThinkingEffort | None) -> bool: + return effort is not None and effort != "off" + + +def apply_login_thinking_defaults( + config: Config, *, thinking: bool, effort: ThinkingEffort +) -> None: + """Initialize login-time thinking defaults without clobbering an explicit choice. + + A ``/login`` flow reconfigures the default provider/model, but the effort dial is a + cross-session user preference. ``create_llm`` clamps effort to the chosen model's + capabilities at use-time, so a previously-set value is always safe to keep; only an + unset (``None``) effort is initialized here. + """ + if config.default_thinking_effort is not None: + return + config.default_thinking = thinking + config.default_thinking_effort = effort + + +def normalize_thinking_effort(value: str | None) -> ThinkingEffort | None: + """Return a known user-facing effort level.""" + if value is None: + return None + if value in EXTENDED_THINKING_LEVELS: + return value + return None + + +def effective_config_thinking_effort( + default_thinking: bool, + default_thinking_effort: ThinkingEffort | None, +) -> ThinkingEffort: + """Resolve persisted config fields into one effective default effort. + + ``default_thinking_effort`` is the source of truth: when it is set (including + an explicit ``"off"``) it wins. The legacy ``default_thinking`` bool is only a + backward-compat fallback for configs written before the effort field existed + (effort is ``None``): true -> high, false -> off. + """ + if default_thinking_effort is not None: + return default_thinking_effort + return DEFAULT_THINKING_EFFORT if default_thinking else "off" + + +def model_uses_native_thinking(capabilities: Collection[str] | None) -> bool: + """Return true when reasoning is built into the model, not a user effort dial.""" + return bool( + capabilities and "always_thinking" in capabilities and "thinking" not in capabilities + ) + + +def available_thinking_levels(capabilities: Collection[str] | None) -> tuple[ThinkingEffort, ...]: + """Return selectable levels for a model capability set.""" + if not capabilities or "thinking" not in capabilities: + return ("off",) + if "always_thinking" in capabilities: + return tuple(level for level in THINKING_LEVELS if level != "off") + return THINKING_LEVELS + + +def clamp_thinking_effort( + effort: ThinkingEffort, + levels: Sequence[ThinkingEffort], +) -> ThinkingEffort: + """Clamp *effort* to the nearest selectable entry in *levels*. + + Match pi-main's behavior: if the exact level is unsupported, first search + upward for a stronger available level, then downward. This keeps requests + like ``xhigh`` on high-only models useful without silently disabling + thinking. + """ + if not levels: + return effort + if effort in levels: + return effort + + try: + requested_index = EXTENDED_THINKING_LEVELS.index(effort) + except ValueError: + return levels[0] + + for candidate in EXTENDED_THINKING_LEVELS[requested_index + 1 :]: + if candidate in levels: + return candidate + for candidate in reversed(EXTENDED_THINKING_LEVELS[:requested_index]): + if candidate in levels: + return candidate + return levels[0] + + +def next_thinking_level( + current: ThinkingEffort, + levels: Sequence[ThinkingEffort] = THINKING_LEVELS, +) -> ThinkingEffort: + """Return the next level in *levels*, wrapping at the end.""" + if not levels: + return current + if current not in levels: + # Unselectable current state (e.g. ``off`` on an always-thinking model): + # land on the lowest valid level rather than skipping past it. + return levels[0] + index = levels.index(current) + return levels[(index + 1) % len(levels)] diff --git a/src/pythinker_code/tools/agent/__init__.py b/src/pythinker_code/tools/agent/__init__.py index 29b66b3f..f4bbc79f 100644 --- a/src/pythinker_code/tools/agent/__init__.py +++ b/src/pythinker_code/tools/agent/__init__.py @@ -374,6 +374,11 @@ async def _run_in_background(self, params: Params) -> ToolReturnValue: thinking=self._runtime.llm.thinking if self._runtime.llm is not None else None, + thinking_effort=( + self._runtime.llm.thinking_effort + if self._runtime.llm is not None + else None + ), parent_agent_id=self._runtime.subagent_id, ), ) @@ -457,6 +462,7 @@ async def _run_in_background(self, params: Params) -> ToolReturnValue: def _run_agents_fingerprint(params: RunAgentsParams) -> str: payload = { "summary": params.summary, + "base_prompt": params.base_prompt, "agent_count": len(params.agents), "agent_names": [agent.name for agent in params.agents], "subagent_types": [agent.subagent_type or "coder" for agent in params.agents], diff --git a/src/pythinker_code/tools/ask_user/__init__.py b/src/pythinker_code/tools/ask_user/__init__.py index a93816ca..8baa5250 100644 --- a/src/pythinker_code/tools/ask_user/__init__.py +++ b/src/pythinker_code/tools/ask_user/__init__.py @@ -2,7 +2,7 @@ import json import logging -from collections.abc import Callable +from collections.abc import Awaitable, Callable from pathlib import Path from typing import Literal, override from uuid import uuid4 @@ -25,7 +25,7 @@ NAME = "AskUserQuestion" -AskUserPolicy = Literal["always", "ask_except_auto", "never"] +AskUserPolicy = Literal["always", "ask_except_auto", "never", "auto_deliberate"] _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "description.md") @@ -76,6 +76,7 @@ def __init__(self) -> None: super().__init__() self._is_auto: Callable[[], bool] | None = None self._policy: AskUserPolicy = "ask_except_auto" + self._advisor: Callable[[list[QuestionItem]], Awaitable[str | None]] | None = None def bind_auto( self, @@ -86,8 +87,52 @@ def bind_auto( self._is_auto = is_auto self._policy = policy + def bind_deliberation( + self, + advisor: Callable[[list[QuestionItem]], Awaitable[str | None]], + ) -> None: + """Late-bind the blind advisor used under the ``auto_deliberate`` policy.""" + self._advisor = advisor + + def _build_question_items(self, params: Params) -> list[QuestionItem]: + return [ + QuestionItem( + question=q.question, + header=q.header, + options=[ + QuestionOption(label=o.label, description=o.description) for o in q.options + ], + multi_select=q.multi_select, + ) + for q in params.questions + ] + @override async def __call__(self, params: Params) -> ToolReturnValue: + in_auto = bool(self._is_auto and self._is_auto()) + if self._policy == "auto_deliberate" and in_auto: + # Entry A: no user is present, but instead of dismissing we run an + # independent blind advisor over the options and hand the verdict + # back so the agent makes a reasoned self-decision on its next turn. + items = self._build_question_items(params) + verdict = await self._advisor(items) if self._advisor else None + payload: dict[str, object] = { + "answers": {}, + "note": ( + "No user is present. You are the decider: commit to one option with a " + "one-line justification, then proceed. Do not call AskUserQuestion again " + "for this decision." + ), + } + if verdict: + payload["advisor"] = verdict + return ToolReturnValue( + is_error=False, + output=json.dumps(payload, ensure_ascii=False), + message="Auto-deliberation: advisor consulted; agent decides.", + display=[BriefDisplayBlock(text="Deliberating (advisor consulted)")], + ) + if self._policy == "never" or ( self._policy == "ask_except_auto" and self._is_auto and self._is_auto() ): @@ -117,17 +162,7 @@ async def __call__(self, params: Params) -> ToolReturnValue: brief="Invalid context", ) - questions = [ - QuestionItem( - question=q.question, - header=q.header, - options=[ - QuestionOption(label=o.label, description=o.description) for o in q.options - ], - multi_select=q.multi_select, - ) - for q in params.questions - ] + questions = self._build_question_items(params) request = QuestionRequest( id=str(uuid4()), diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index f84321ca..0f1d41d0 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -682,6 +682,11 @@ async def _plan_mode_toggle() -> bool: return await self.soul.toggle_plan_mode_from_manual() return False + async def _thinking_effort_cycle() -> str | None: + if isinstance(self.soul, PythinkerSoul): + return self.soul.cycle_thinking_effort_from_manual() + return None + def _mcp_status_block(columns: int): if not isinstance(self.soul, PythinkerSoul): return None @@ -729,6 +734,9 @@ def _bg_task_counts() -> BgTaskCounts: else None, ), thinking=self.soul.thinking or False, + thinking_effort=( + self.soul.thinking_effort if isinstance(self.soul, PythinkerSoul) else None + ), agent_mode_slash_commands=list(self._available_slash_commands.values()), shell_mode_slash_commands=shell_mode_registry.list_commands(), editor_command_provider=lambda: ( @@ -737,6 +745,7 @@ def _bg_task_counts() -> BgTaskCounts: else "" ), plan_mode_toggle_callback=_plan_mode_toggle, + thinking_effort_cycle_callback=_thinking_effort_cycle, history_enabled=( self.soul.runtime.config.tui.prompt_history_enabled if isinstance(self.soul, PythinkerSoul) diff --git a/src/pythinker_code/ui/shell/components/report.py b/src/pythinker_code/ui/shell/components/report.py index 2ba2b342..933a3b3c 100644 --- a/src/pythinker_code/ui/shell/components/report.py +++ b/src/pythinker_code/ui/shell/components/report.py @@ -24,13 +24,16 @@ if TYPE_CHECKING: from markdown_it import MarkdownIt +from rich import box from rich.console import Group, RenderableType from rich.padding import Padding +from rich.panel import Panel from rich.rule import Rule 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.shell.spacing import REPORT_PANEL_PADDING from pythinker_code.ui.theme import ThemeName, tui_rich_style _log = logging.getLogger(__name__) @@ -160,7 +163,12 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab rows.append(title) if finding.location: - rows.append(Text(f" {finding.location}", style=tui_rich_style("dim", theme=theme))) + # Keep wrapped file paths in the same hanging-indent column. A raw + # leading-space Text only indents the first physical line after Rich + # wraps, which makes long locations drift left inside wide reports. + rows.append( + Padding(Text(finding.location, style=tui_rich_style("dim", theme=theme)), (0, 0, 0, 2)) + ) if finding.body.strip(): rows.append(Padding(pythinker_markdown(finding.body.strip()), (0, 0, 0, 2))) @@ -169,17 +177,15 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab def render_report(report: Report, *, theme: ThemeName | None = None) -> RenderableType: - """Render *report* as a muted, roomy Rich renderable (no outer box).""" + """Render *report* as a padded, syntax-friendly Rich report panel.""" counts = _counts(report.findings) border = tui_rich_style("border_muted", theme=theme) blank = Text("") - rows: list[RenderableType] = [ - Text(report.title, style=tui_rich_style("text", theme=theme) + RichStyle(bold=True)), - ] + rows: list[RenderableType] = [] if report.scope: - rows += [blank, Text(report.scope, style=tui_rich_style("dim", theme=theme))] - rows += [blank, _summary_line(counts, theme)] + rows += [Text(report.scope, style=tui_rich_style("dim", theme=theme)), blank] + rows.append(_summary_line(counts, theme)) for severity in _SEVERITY_ORDER: group = [f for f in report.findings if f.severity == severity] @@ -198,9 +204,16 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab Text(report.note, style=tui_rich_style("muted", theme=theme)), ] - # One column of left breathing room; vertical roominess comes from the - # blank rows between sections and findings. - return Padding(Group(*rows), (0, 0, 0, 1)) + title = Text(report.title, style=tui_rich_style("text", theme=theme) + RichStyle(bold=True)) + return Panel( + Group(*rows), + title=title, + title_align="left", + border_style=border, + box=box.ROUNDED, + padding=REPORT_PANEL_PADDING, + expand=True, + ) def parse_report_block(payload: str) -> Report | None: diff --git a/src/pythinker_code/ui/shell/keymap.py b/src/pythinker_code/ui/shell/keymap.py index d426fd21..a31f6b78 100644 --- a/src/pythinker_code/ui/shell/keymap.py +++ b/src/pythinker_code/ui/shell/keymap.py @@ -49,7 +49,7 @@ class KeybindingInfo: _REGISTRY: dict[str, tuple[str, ...]] = { "app.prompt.help": ("?",), "app.mode.toggle": ("ctrl+x",), - "app.plan.toggle": ("shift+tab",), + "app.thinking.cycle": ("shift+tab",), "app.shell.oneshot": ("!",), "app.editor.external": ("ctrl+o",), "app.prompt.newline": ("ctrl+j", "alt+enter"), @@ -68,7 +68,7 @@ class KeybindingInfo: _BINDING_DESCRIPTIONS: dict[str, str] = { "app.prompt.help": "show shortcuts", "app.mode.toggle": "toggle agent/shell prompt", - "app.plan.toggle": "toggle plan mode", + "app.thinking.cycle": "change thinking effort", "app.shell.oneshot": "run one shell command", "app.editor.external": "open prompt in editor", "app.prompt.newline": "insert newline", @@ -87,7 +87,7 @@ class KeybindingInfo: _BINDING_CONTEXTS: dict[str, str] = { "app.prompt.help": "prompt", "app.mode.toggle": "prompt", - "app.plan.toggle": "prompt", + "app.thinking.cycle": "prompt", "app.shell.oneshot": "agent prompt", "app.editor.external": "prompt", "app.prompt.newline": "prompt", @@ -106,7 +106,7 @@ class KeybindingInfo: _BINDING_ORDER: tuple[str, ...] = ( "app.prompt.help", "app.mode.toggle", - "app.plan.toggle", + "app.thinking.cycle", "app.shell.oneshot", "app.editor.external", "app.prompt.newline", diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index c77df97f..fa7fe851 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -31,24 +31,25 @@ def verb_spinner_style() -> Style: - """Muted yellow style for the active verb spinner word.""" + """Muted orange-yellow style for the active verb spinner word.""" if colors_disabled(): return Style() - return Style(color=Color.parse("#E6B450")) # brand-exception: muted yellow verb shimmer + return Style(color=Color.parse(_SHIMMER_BASE)) -# ChatGPT-like clean shimmer: a subtle muted-yellow sweep on the active verb only. -_SHIMMER_BASE = "#E6B450" # brand-exception: muted yellow shimmer literal -_SHIMMER_MID = "#EBC46E" # brand-exception: muted yellow shimmer literal -_SHIMMER_HIGHLIGHT = "#F3D89A" # brand-exception: muted yellow shimmer literal +# Terminal-native shimmer: a restrained silver sheen sweeping over the muted +# orange-yellow active verb. +_SHIMMER_BASE = "#D49E5A" # brand-exception: muted orange-yellow verb literal +_SHIMMER_MID = "#E2C18A" # brand-exception: light warm amber sheen-trail literal +_SHIMMER_HIGHLIGHT = "#D8DCE2" # brand-exception: silver sheen highlight literal _SHIMMER_INTERVAL_S = 0.22 _SPINNER_SILVER_STYLE = Style(color=Color.parse("#C0C0C0")) # brand-exception: silver spinner def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> Style: - """Clean muted-yellow shimmer color for active verb text. + """Clean shimmer color for active verb text. - Reduced motion pins to the base muted yellow so the word stays calm. + Reduced motion pins to the base muted orange-yellow so the word stays calm. """ if colors_disabled(): return Style() @@ -59,13 +60,72 @@ def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> return Style(color=Color.parse(palette[idx])) +def _wave_colors(chars: list[str], local_phase: int, *, rightward: bool) -> list[str | None]: + """Per-character colors for a single traveling-wave sweep. + + One bright highlight crosses the label with an asymmetric, slightly wider + trail behind it — an angled sheen rather than a flat pulse. ``rightward`` + flips both the travel direction and the trailing side so the trail always + lags behind the head. + """ + n = len(chars) + if rightward: + head = local_phase - 2 + trail = (1, -1, -2, -3) + else: + head = n + 2 - local_phase + trail = (-1, 1, 2, 3) + colors: list[str | None] = [] + for i, char in enumerate(chars): + if char.isspace(): + colors.append(None) + continue + offset = i - head + if offset == 0: + colors.append(_SHIMMER_HIGHLIGHT) + elif offset in trail: + colors.append(_SHIMMER_MID) + else: + colors.append(_SHIMMER_BASE) + return colors + + +def _splash_colors(chars: list[str], local_phase: int) -> list[str | None]: + """Per-character colors for the center-out splash bloom. + + A wavefront expands from the middle of the label toward both edges, leaving + a filled coral interior behind it, then settles the whole word to base amber. + """ + n = len(chars) + fill_frames = (n + 1) // 2 + 1 # frames for the wavefront to clear both edges + center = (n - 1) / 2 + if local_phase >= fill_frames: # settle beat before the next wave launches + return [None if char.isspace() else _SHIMMER_BASE for char in chars] + radius = local_phase + colors: list[str | None] = [] + for i, char in enumerate(chars): + if char.isspace(): + colors.append(None) + continue + dist = abs(i - center) + if radius - 0.5 <= dist <= radius + 0.5: + colors.append(_SHIMMER_HIGHLIGHT) + elif dist < radius - 0.5: + colors.append(_SHIMMER_MID) + else: + colors.append(_SHIMMER_BASE) + return colors + + def _shimmer_segments( label: str, elapsed_s: float, *, reduced_motion: bool ) -> list[tuple[str | None, str]]: """Return coalesced ``(hex_color, text)`` shimmer segments. This is shared by Rich renderables and prompt_toolkit fragments so every - active-work label uses the same visual language. + active-work label uses the same visual language. The motion is a four-phase + loop that reads like traveling waves: a wave sweeps right-to-left, splashes + outward from the middle, sweeps back left-to-right, splashes again, repeat. """ if not label: return [] @@ -75,22 +135,22 @@ def _shimmer_segments( return [(_SHIMMER_BASE, label)] chars = list(label) - # Sweep one bright highlight right-to-left with an asymmetric, slightly - # wider trail. The uneven trail reads like an angled sheen instead of a flat pulse. - phase = int(max(0.0, elapsed_s) / _SHIMMER_INTERVAL_S) % (len(chars) + 6) - head = len(chars) + 2 - phase + n = len(chars) + wave_len = n + 6 + splash_len = (n + 1) // 2 + 3 + cycle_len = 2 * wave_len + 2 * splash_len + frame = int(max(0.0, elapsed_s) / _SHIMMER_INTERVAL_S) % cycle_len + if frame < wave_len: + colors = _wave_colors(chars, frame, rightward=False) + elif frame < wave_len + splash_len: + colors = _splash_colors(chars, frame - wave_len) + elif frame < 2 * wave_len + splash_len: + colors = _wave_colors(chars, frame - wave_len - splash_len, rightward=True) + else: + colors = _splash_colors(chars, frame - 2 * wave_len - splash_len) + segments: list[tuple[str | None, str]] = [] - for i, char in enumerate(chars): - if char.isspace(): - color: str | None = None - else: - offset = i - head - if offset == 0: - color = _SHIMMER_HIGHLIGHT - elif offset in (-1, 1, 2, 3): - color = _SHIMMER_MID - else: - color = _SHIMMER_BASE + for char, color in zip(chars, colors, strict=True): if segments and segments[-1][0] == color: segments[-1] = (color, segments[-1][1] + char) else: diff --git a/src/pythinker_code/ui/shell/oauth.py b/src/pythinker_code/ui/shell/oauth.py index eb8b576a..8cdd9185 100644 --- a/src/pythinker_code/ui/shell/oauth.py +++ b/src/pythinker_code/ui/shell/oauth.py @@ -13,27 +13,34 @@ LM_STUDIO_PLATFORM_ID, MINIMAX_PLATFORM_ID, OLLAMA_PLATFORM_ID, + OPENAI_API_PLATFORM_ID, + OPENAI_CHATGPT_PLATFORM_ID, OPENCODE_GO_PLATFORM_ID, OPENROUTER_PLATFORM_ID, ) from pythinker_code.auth.anthropic_direct import ( + ANTHROPIC_PROVIDER_KEY, login_anthropic_api_key, logout_anthropic, ) from pythinker_code.auth.deepseek import ( + DEEPSEEK_PROVIDER_KEY, login_deepseek_api_key, logout_deepseek, ) from pythinker_code.auth.lm_studio import ( + LM_STUDIO_PROVIDER_KEY, login_lm_studio, logout_lm_studio, ) from pythinker_code.auth.minimax import ( + MINIMAX_ANTHROPIC_PROVIDER_KEY, login_minimax_api_key, logout_minimax, ) from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.auth.ollama import ( + OLLAMA_PROVIDER_KEY, login_ollama, logout_ollama, ) @@ -44,13 +51,17 @@ logout_openai, ) from pythinker_code.auth.opencode_go import ( + OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, + OPENCODE_GO_OPENAI_PROVIDER_KEY, login_opencode_go_api_key, logout_opencode_go, ) from pythinker_code.auth.openrouter import ( + OPENROUTER_PROVIDER_KEY, login_openrouter_api_key, logout_openrouter, ) +from pythinker_code.auth.platforms import managed_provider_key from pythinker_code.cli import Reload from pythinker_code.ui.shell.console import console from pythinker_code.ui.shell.selectors.oauth import ( @@ -62,6 +73,7 @@ from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens if TYPE_CHECKING: + from pythinker_code.config import Config from pythinker_code.soul.pythinkersoul import PythinkerSoul from pythinker_code.ui.shell import Shell @@ -121,7 +133,45 @@ async def _prompt_api_key(label: str) -> str | None: ] -def _get_provider_status(provider_id: str) -> OAuthProviderStatus: +# Selector/command id -> the managed provider keys whose presence in the config +# means that provider is logged in. The OpenAI ChatGPT OAuth login (browser and +# device-code) and the OpenAI API-key login store distinct provider keys; the +# "openai" logout entry covers both. +_PROVIDER_KEYS: dict[str, tuple[str, ...]] = { + "browser": (managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID),), + "headless": (managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID),), + "api-key": (managed_provider_key(OPENAI_API_PLATFORM_ID),), + "openai": ( + managed_provider_key(OPENAI_API_PLATFORM_ID), + managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID), + ), + "opencode-go": (OPENCODE_GO_OPENAI_PROVIDER_KEY, OPENCODE_GO_ANTHROPIC_PROVIDER_KEY), + "minimax": (MINIMAX_ANTHROPIC_PROVIDER_KEY,), + "deepseek": (DEEPSEEK_PROVIDER_KEY,), + "anthropic": (ANTHROPIC_PROVIDER_KEY,), + "openrouter": (OPENROUTER_PROVIDER_KEY,), + "lm-studio": (LM_STUDIO_PROVIDER_KEY,), + "ollama": (OLLAMA_PROVIDER_KEY,), +} + +# Providers offered by the no-argument /logout selector, one entry per provider +# (a single OpenAI entry that clears both OpenAI credentials). +_LOGOUT_PROVIDER_ENTRIES: list[OAuthProviderEntry] = [ + OAuthProviderEntry(id="openai", name="OpenAI", auth_type="oauth"), + OAuthProviderEntry(id="opencode-go", name="OpenCode Go", auth_type="api_key"), + OAuthProviderEntry(id="minimax", name="MiniMax", auth_type="api_key"), + OAuthProviderEntry(id="deepseek", name="DeepSeek", auth_type="api_key"), + OAuthProviderEntry(id="anthropic", name="Anthropic", auth_type="api_key"), + OAuthProviderEntry(id="openrouter", name="OpenRouter", auth_type="api_key"), + OAuthProviderEntry(id="lm-studio", name="LM Studio", auth_type="api_key"), + OAuthProviderEntry(id="ollama", name="Ollama", auth_type="api_key"), +] + + +def _get_provider_status(config: Config, provider_id: str) -> OAuthProviderStatus: + keys = _PROVIDER_KEYS.get(provider_id, ()) + if any(key in config.providers for key in keys): + return OAuthProviderStatus(source="configured") return OAuthProviderStatus(source="unconfigured") @@ -141,11 +191,12 @@ async def login(app: Shell, args: str) -> None: soul = ensure_pythinker_soul(app) if soul is None: return + config = soul.runtime.config mode = args.strip().lower() if mode == "": chosen = await run_oauth_selector( _SELECTOR_PROVIDER_ENTRIES, - _get_provider_status, + lambda provider_id: _get_provider_status(config, provider_id), action="login", ) if chosen is None: @@ -239,7 +290,27 @@ async def logout(app: Shell, args: str) -> None: ) return mode = args.strip().lower() - if mode == "openrouter": + if mode == "": + configured = [ + entry + for entry in _LOGOUT_PROVIDER_ENTRIES + if _get_provider_status(config, entry.id).source == "configured" + ] + if not configured: + console.print(f"[{_t.info}]No providers are logged in.[/]") + return + chosen = await run_oauth_selector( + configured, + lambda provider_id: _get_provider_status(config, provider_id), + action="logout", + ) + if chosen is None: + return + mode = chosen + + if mode == "openai": + ok = await _render_oauth_events(logout_openai(config)) + elif mode == "openrouter": ok = await _render_oauth_events(logout_openrouter(config)) elif mode == "anthropic": ok = await _render_oauth_events(logout_anthropic(config)) @@ -259,12 +330,10 @@ async def logout(app: Shell, args: str) -> None: delete_github_feedback_token() console.print(f"[{_t.success}]Logged out of GitHub feedback.[/]") ok = True - elif mode == "": - ok = await _render_oauth_events(logout_openai(config)) else: console.print( f"[{_t.error}]Usage: /logout " - "[opencode-go|minimax|deepseek|anthropic|openrouter|lm-studio|ollama|" + "[openai|opencode-go|minimax|deepseek|anthropic|openrouter|lm-studio|ollama|" "github-feedback][/]" ) return diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index da1d476a..92cf99dc 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -64,6 +64,7 @@ from pythinker_code.llm import ModelCapability from pythinker_code.share import get_share_dir from pythinker_code.soul import StatusSnapshot, format_context_status +from pythinker_code.thinking import model_uses_native_thinking from pythinker_code.tools.display import TodoDisplayItem from pythinker_code.ui.shell import placeholders as prompt_placeholders from pythinker_code.ui.shell.console import console @@ -80,7 +81,7 @@ ) from pythinker_code.ui.shell.spacing import ensure_prompt_newline from pythinker_code.ui.shell.spinner_words import spinner_message -from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors +from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors, thinking_frame_style from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens from pythinker_code.ui.tui_config import is_card_style from pythinker_code.utils.clipboard import ( @@ -316,6 +317,17 @@ def _card_side_indent() -> str: return " " * _card_side_padding() +def _prompt_rule(columns: int) -> str: + """Return a prompt-toolkit-safe horizontal rule for the current terminal width. + + prompt_toolkit can leave resize artifacts when non-fullscreen prompts draw + visible content through the last terminal column; full-width bottom bars make + the duplicated prompt spam especially obvious. Keep the rightmost column + blank for prompt-owned chrome while still visually reading as a full rule. + """ + return "─" * max(0, columns - 1) + + def _truncate_to_width(text: str, width: int) -> str: if width <= 0: return "" @@ -1803,7 +1815,7 @@ def _tip(binding: str, fallback: str, description: str) -> str: tips = [ _tip("app.prompt.help", "?", "shortcuts"), _tip("app.mode.toggle", "ctrl-x", "toggle mode"), - _tip("app.plan.toggle", "shift-tab", "plan mode"), + _tip("app.thinking.cycle", "shift-tab", "change thinking effort"), _tip("app.shell.oneshot", "!", "shell command"), _tip("app.editor.external", "ctrl-o", "editor"), _tip("app.todos.toggle", "ctrl-t", "toggle todos"), @@ -1831,10 +1843,12 @@ def __init__( model_capabilities: set[ModelCapability], model_name: str | None, thinking: bool, - agent_mode_slash_commands: Sequence[SlashCommand[Any]], + thinking_effort: str | None = None, + agent_mode_slash_commands: Sequence[SlashCommand[Any]] = (), shell_mode_slash_commands: Sequence[SlashCommand[Any]], editor_command_provider: Callable[[], str] = lambda: "", plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None, + thinking_effort_cycle_callback: Callable[[], Awaitable[str | None]] | None = None, history_enabled: bool = True, ) -> None: history_dir = get_share_dir() / "user-history" @@ -1854,11 +1868,13 @@ def __init__( self._background_task_count_provider = background_task_count_provider self._editor_command_provider = editor_command_provider self._plan_mode_toggle_callback = plan_mode_toggle_callback + self._thinking_effort_cycle_callback = thinking_effort_cycle_callback self._model_capabilities = model_capabilities self._model_name = model_name self._last_history_content: str | None = None self._mode: PromptMode = PromptMode.AGENT self._thinking = thinking + self._thinking_effort = thinking_effort or ("high" if thinking else "off") self._placeholder_manager = PromptPlaceholderManager() # Keep the old attribute for test compatibility and for any external imports. self._attachment_cache = self._placeholder_manager.attachment_cache @@ -1984,24 +2000,41 @@ def _(event: KeyPressEvent) -> None: @_kb.add("s-tab", eager=True) def _(event: KeyPressEvent) -> None: - """Toggle plan mode with Shift+Tab.""" + """Cycle thinking effort with Shift+Tab.""" if self._active_prompt_delegate() is not None: return - if self._plan_mode_toggle_callback is not None: + if self._thinking_effort_cycle_callback is not None: - async def _toggle() -> None: - assert self._plan_mode_toggle_callback is not None - new_state = await self._plan_mode_toggle_callback() + async def _cycle() -> None: + assert self._thinking_effort_cycle_callback is not None + new_level = await self._thinking_effort_cycle_callback() from pythinker_code.telemetry import track - track("shortcut_plan_toggle", enabled=new_state) - if new_state: - toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True) + if new_level is None: + message = ( + "Current model uses native reasoning" + if self._uses_native_thinking() + else "Current model does not support thinking" + ) + toast( + message, + topic="thinking_level", + duration=3.0, + immediate=True, + ) else: - toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True) + self._thinking_effort = new_level + self._thinking = new_level != "off" + track("shortcut_thinking_cycle", level=new_level) + toast( + f"Thinking level: {new_level}", + topic="thinking_level", + duration=3.0, + immediate=True, + ) event.app.invalidate() - event.app.create_background_task(_toggle()) + event.app.create_background_task(_cycle()) event.app.invalidate() @_kb.add("escape", "enter", eager=True) @@ -2365,7 +2398,7 @@ def _install_prompt_buffer_visibility(self) -> None: buffer_window = buffer_container.content buffer_window.height = Dimension(min=1, max=5) buffer_window.dont_extend_height = Condition(lambda: True) - buffer_window.style = "class:compact-input" + buffer_window.style = self._thinking_input_style buffer_window.right_margins = [ *buffer_window.right_margins, _PromptRightPaddingMargin(self._input_right_padding), @@ -2389,6 +2422,39 @@ def _mention_menu_left_padding(self) -> int: def _input_right_padding(self) -> int: return _INPUT_RIGHT_PADDING + def _current_thinking_effort(self) -> str: + return getattr(self, "_thinking_effort", None) or ( + "high" if getattr(self, "_thinking", False) else "off" + ) + + def _thinking_input_style(self) -> str: + """Keep typed input text on the normal prompt color, independent of thinking effort.""" + return "class:compact-input" + + def _thinking_prompt_prefix_style(self) -> str: + """Keep the prompt marker on the normal prompt color, independent of thinking effort.""" + return "class:compact-input.prompt" + + def _uses_native_thinking(self) -> bool: + return model_uses_native_thinking(getattr(self, "_model_capabilities", None)) + + def _prompt_separator_style(self, fallback: str) -> str: + if getattr(self, "_mode", PromptMode.AGENT) != PromptMode.AGENT: + return fallback + level = "high" if self._uses_native_thinking() else self._current_thinking_effort() + return thinking_frame_style(level) or fallback + + def _thinking_footer_label(self) -> str: + if self._uses_native_thinking(): + return "native reasoning" + level = self._current_thinking_effort() + return "thinking off" if level == "off" else level + + def _mode_model_thinking_label(self) -> str: + if not self._model_name: + return str(self._mode) + return f"{self._mode} {self._model_name} • {self._thinking_footer_label()}" + def _render_prompt_continuation( self, width: int, @@ -2397,7 +2463,7 @@ def _render_prompt_continuation( ) -> FormattedText: """Indent wrapped input rows to the same column as the first text row.""" del line_number, is_soft_wrap - return FormattedText([("class:compact-input", " " * max(0, width))]) + return FormattedText([(self._thinking_input_style(), " " * max(0, width))]) def _render_message(self) -> FormattedText: if self._mode == PromptMode.SHELL: @@ -2442,7 +2508,7 @@ def _render_shell_prompt_message(self) -> FormattedText: if is_card_style(): ensure_prompt_newline(fragments) tc = get_toolbar_colors() - fragments.append((tc.separator, "─" * columns)) + fragments.append((tc.separator, _prompt_rule(columns))) fragments.append(("", "\n")) elif preamble: fragments.append(("", "\n")) @@ -2649,12 +2715,12 @@ def _render_agent_prompt_message(self) -> FormattedText: if is_card_style(): ensure_prompt_newline(fragments) tc = get_toolbar_colors() - fragments.append((tc.separator, "─" * columns)) + fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) fragments.append(("", _card_side_indent())) else: fragments.append(("", "\n")) - fragments.append(("class:compact-input.prompt", f"{PROMPT_SYMBOL_AGENT_INPUT} ")) + fragments.append((self._thinking_prompt_prefix_style(), f"{PROMPT_SYMBOL_AGENT_INPUT} ")) return fragments def _render_shortcut_help(self, columns: int) -> FormattedText: @@ -2668,7 +2734,7 @@ def _render_shortcut_help(self, columns: int) -> FormattedText: help_ids = { "app.prompt.help", "app.mode.toggle", - "app.plan.toggle", + "app.thinking.cycle", "app.shell.oneshot", "app.editor.external", "app.prompt.newline", @@ -3226,7 +3292,7 @@ def _render_bottom_toolbar(self) -> FormattedText: fragments: list[tuple[str, str]] = [] tc = get_toolbar_colors() - fragments.append((tc.separator, "─" * columns)) + fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) remaining = columns @@ -3257,14 +3323,14 @@ def _render_bottom_toolbar(self) -> FormattedText: secondary_style = f"fg:{tokens.muted}" mode = str(self._mode) if self._mode == PromptMode.AGENT and self._model_name: - thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" - mode_full = f"{mode} ({self._model_name} {thinking_dot})" - mode_mid = f"{mode} {thinking_dot}" + thinking_label = self._thinking_footer_label() + mode_full = f"{mode} ({self._model_name} • {thinking_label})" + mode_mid = f"{mode} {thinking_label}" if _display_width(mode_full) <= remaining - 2: mode = mode_full elif _display_width(mode_mid) <= remaining - 2: mode = mode_mid - # else: keep bare mode name — model_name and dot are both dropped + # else: keep bare mode name — model_name and thinking label are both dropped fragments.extend([(mode_style, mode), ("", " ")]) remaining -= _display_width(mode) + 2 @@ -3368,7 +3434,7 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText: mode_style = f"fg:{tokens.text or tokens.activity_label}" secondary_style = f"fg:{tokens.muted}" - fragments.append((tc.separator, "─" * columns)) + fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) # ── line 1: cwd + git + status flags ─────────────────────────────── @@ -3427,9 +3493,7 @@ def _append_right(style: str, text: str) -> None: ) _append_right(secondary_style, ctx_compact) if self._model_name: - thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" - mode = str(self._mode) - _append_right(mode_style, f"{mode} {self._model_name} {thinking_dot}") + _append_right(mode_style, self._mode_model_thinking_label()) right_text = " ".join(right_parts) right_width = _display_width(right_text) if right_width > columns: diff --git a/src/pythinker_code/ui/shell/selectors/settings.py b/src/pythinker_code/ui/shell/selectors/settings.py index 8c02e388..e0d486ab 100644 --- a/src/pythinker_code/ui/shell/selectors/settings.py +++ b/src/pythinker_code/ui/shell/selectors/settings.py @@ -10,6 +10,12 @@ from typing import Any, cast from pythinker_code.config import Config +from pythinker_code.llm import derive_model_capabilities +from pythinker_code.thinking import ( + EXTENDED_THINKING_LEVELS, + effective_config_thinking_effort, + model_uses_native_thinking, +) from pythinker_code.ui.shell.components.settings_list import ( SettingItem, SettingsListConfig, @@ -44,6 +50,35 @@ def _build_settings_config(config: Config) -> SettingsListConfig: """Build the settings-list config from a Pythinker ``Config`` object.""" model_values = [_NONE_MODEL_VALUE, *sorted(config.models)] current_model = config.default_model or _NONE_MODEL_VALUE + current_model_cfg = config.models.get(config.default_model) if config.default_model else None + default_model_uses_native_thinking = bool( + current_model_cfg + and model_uses_native_thinking(derive_model_capabilities(current_model_cfg)) + ) + default_thinking_value = ( + "native reasoning" + if default_model_uses_native_thinking + else effective_config_thinking_effort( + config.default_thinking, config.default_thinking_effort + ) + ) + default_thinking_values = ( + None + if default_model_uses_native_thinking + else ( + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + ) + ) + default_thinking_description = ( + "Current default model uses built-in reasoning; no effort toggle is available." + if default_model_uses_native_thinking + else "Default reasoning effort for thinking-capable models." + ) items = [ SettingItem( @@ -79,12 +114,9 @@ def _build_settings_config(config: Config) -> SettingsListConfig: SettingItem( id="default_thinking", label="Default thinking", - description=( - "Default reasoning mode. Pythinker currently persists this as bool, " - "so settings exposes off/high only." - ), - current_value="high" if config.default_thinking else "off", - values=("off", "high"), + description=default_thinking_description, + current_value=default_thinking_value, + values=default_thinking_values, ), SettingItem( id="show_thinking_stream", @@ -267,9 +299,12 @@ def mark(setting_id: str) -> None: config.default_model = model mark(setting_id) case "default_thinking": + if value not in EXTENDED_THINKING_LEVELS: + continue enabled = value != "off" - if config.default_thinking != enabled: + if config.default_thinking != enabled or config.default_thinking_effort != value: config.default_thinking = enabled + config.default_thinking_effort = value mark(setting_id) case "show_thinking_stream": new = value == "true" diff --git a/src/pythinker_code/ui/shell/selectors/thinking.py b/src/pythinker_code/ui/shell/selectors/thinking.py index 2d4dddc7..0eacabc4 100644 --- a/src/pythinker_code/ui/shell/selectors/thinking.py +++ b/src/pythinker_code/ui/shell/selectors/thinking.py @@ -1,19 +1,27 @@ from __future__ import annotations -from typing import Literal - +from typing import Literal, cast + +from pythinker_code.thinking import ( + LEVEL_DESCRIPTIONS, +) +from pythinker_code.thinking import ( + THINKING_LEVELS as CORE_THINKING_LEVELS, +) +from pythinker_code.thinking import ( + next_thinking_level as _next_thinking_level, +) from pythinker_code.ui.shell.selector import SelectorConfig, SelectorItem, run_selector ThinkingLevel = Literal["off", "minimal", "low", "medium", "high", "xhigh"] -LEVEL_DESCRIPTIONS: dict[str, str] = { - "off": "No reasoning", - "minimal": "Very brief reasoning (~1k tokens)", - "low": "Light reasoning (~2k tokens)", - "medium": "Moderate reasoning (~8k tokens)", - "high": "Deep reasoning (~16k tokens)", - "xhigh": "Maximum reasoning (~32k tokens)", -} +THINKING_LEVELS: tuple[ThinkingLevel, ...] = cast(tuple[ThinkingLevel, ...], CORE_THINKING_LEVELS) +"""Canonical low→high order used by the Shift+Tab cycle.""" + + +def next_thinking_level(current: ThinkingLevel) -> ThinkingLevel: + """Return the next level in the cycle, wrapping ``xhigh`` back to ``off``.""" + return cast(ThinkingLevel, _next_thinking_level(current, THINKING_LEVELS)) def _build_thinking_config( diff --git a/src/pythinker_code/ui/shell/setup.py b/src/pythinker_code/ui/shell/setup.py index fce8fa6f..70b5897f 100644 --- a/src/pythinker_code/ui/shell/setup.py +++ b/src/pythinker_code/ui/shell/setup.py @@ -6,6 +6,7 @@ from prompt_toolkit import PromptSession from prompt_toolkit.shortcuts.choice_input import ChoiceInput from pydantic import SecretStr +from pythinker_core.chat_provider import ThinkingEffort from rich.markup import escape from pythinker_code.auth import PYTHINKER_CODE_PLATFORM_ID @@ -59,8 +60,14 @@ async def setup_platform(platform: Platform) -> bool: return False _apply_setup_result(result) + from pythinker_code.thinking import model_uses_native_thinking + _t = _get_tui_tokens() - thinking_label = "on" if result.thinking else "off" + thinking_label = ( + "native reasoning" + if model_uses_native_thinking(result.selected_model.capabilities) + else result.thinking_effort + ) console.print(f"[{_t.success}]✓ Setup complete![/]") console.print(f" Platform: [bold]{escape(result.platform.name)}[/bold]") console.print(f" Model: [bold]{escape(result.selected_model.id)}[/bold]") @@ -75,6 +82,7 @@ class _SetupResult(NamedTuple): selected_model: ModelInfo models: list[ModelInfo] thinking: bool + thinking_effort: ThinkingEffort async def _setup_platform(platform: Platform) -> _SetupResult | None: @@ -118,22 +126,21 @@ async def _setup_platform(platform: Platform) -> _SetupResult | None: selected_model = model_map[model_id] - # Determine thinking mode based on model capabilities - capabilities = selected_model.capabilities - thinking: bool + # Determine thinking effort based on model capabilities + from pythinker_code.thinking import available_thinking_levels - if "always_thinking" in capabilities: - thinking = True - elif "thinking" in capabilities: + available_efforts = available_thinking_levels(selected_model.capabilities) + if available_efforts == ("off",): + thinking_effort = "off" + else: thinking_selection = await _prompt_choice( - header="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):", - choices=["on", "off"], + header="Select thinking level (↑↓ navigate, Enter select, Ctrl+C cancel):", + choices=list(available_efforts), ) - if not thinking_selection: + if not thinking_selection or thinking_selection not in available_efforts: return None - thinking = thinking_selection == "on" - else: - thinking = False + thinking_effort = thinking_selection + thinking = thinking_effort != "off" return _SetupResult( platform=platform, @@ -141,6 +148,7 @@ async def _setup_platform(platform: Platform) -> _SetupResult | None: selected_model=selected_model, models=models, thinking=thinking, + thinking_effort=thinking_effort, ) @@ -166,6 +174,7 @@ def _apply_setup_result(result: _SetupResult) -> None: ) config.default_model = model_key config.default_thinking = result.thinking + config.default_thinking_effort = result.thinking_effort if result.platform.search_url: config.services.pythinker_ai_search = PythinkerAISearchConfig( diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 561ddc2f..24b50c20 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -226,7 +226,8 @@ def agents(app: Shell, args: str): @registry.command async def model(app: Shell, args: str): """Switch LLM model or thinking mode""" - from pythinker_code.llm import derive_model_capabilities + from pythinker_code.llm import ModelCapability, derive_model_capabilities + from pythinker_code.thinking import model_uses_native_thinking from pythinker_code.ui.theme import get_tui_tokens as _get_tok _t = _get_tok() @@ -257,7 +258,12 @@ async def model(app: Shell, args: str): if model_cfg == curr_model_cfg: curr_model_name = name break - curr_thinking = soul.thinking + curr_capabilities: set[ModelCapability] = ( + derive_model_capabilities(curr_model_cfg) if curr_model_cfg else set() + ) + curr_effort = soul.thinking_effort or ("high" if soul.thinking else "off") + if model_uses_native_thinking(curr_capabilities): + curr_effort = "off" # Step 1: Pick a model — single grouped picker with type-to-filter. from pythinker_code.ui.shell.model_picker import ModelPickerApp, build_provider_groups @@ -281,52 +287,56 @@ async def model(app: Shell, args: str): ) return - # Step 2: Determine thinking mode + # Step 2: Determine thinking effort capabilities = derive_model_capabilities(selected_model_cfg) - new_thinking: bool - - if "always_thinking" in capabilities: - new_thinking = True - elif "thinking" in capabilities: - from pythinker_code.ui.shell.selectors.thinking import ThinkingLevel, run_thinking_selector + from pythinker_code.thinking import available_thinking_levels, clamp_thinking_effort + from pythinker_code.ui.shell.selectors.thinking import ThinkingLevel, run_thinking_selector - _curr_level: ThinkingLevel = "high" if curr_thinking else "off" + native_thinking = model_uses_native_thinking(capabilities) + available_efforts = available_thinking_levels(capabilities) + if native_thinking or available_efforts == ("off",): + new_effort = "off" + else: + current_level = clamp_thinking_effort(curr_effort, available_efforts) _level = await run_thinking_selector( - current_level=_curr_level, - available_levels=["off", "minimal", "low", "medium", "high", "xhigh"], + current_level=cast(ThinkingLevel, current_level), + available_levels=[cast(ThinkingLevel, level) for level in available_efforts], ) if _level is None: return - - new_thinking = _level != "off" - else: - new_thinking = False + new_effort = _level + new_thinking = new_effort != "off" + thinking_label = "native reasoning" if native_thinking else f"thinking {new_effort}" # Check if anything changed model_changed = curr_model_name != selected_model_name - thinking_changed = curr_thinking != new_thinking + thinking_changed = curr_effort != new_effort selected_display = selected_model_cfg.display_name or selected_model_cfg.model if not model_changed and not thinking_changed: console.print( f"[{_t.warning}]Already using {_rich_escape(selected_display)} " - f"with thinking {'on' if new_thinking else 'off'}.[/]" + f"with {thinking_label}.[/]" ) return # Save and reload prev_model = config.default_model prev_thinking = config.default_thinking + prev_effort = config.default_thinking_effort config.default_model = selected_model_name config.default_thinking = new_thinking + config.default_thinking_effort = new_effort try: config_for_save = load_config() config_for_save.default_model = selected_model_name config_for_save.default_thinking = new_thinking + config_for_save.default_thinking_effort = new_effort save_config(config_for_save) except (ConfigError, OSError) as exc: config.default_model = prev_model config.default_thinking = prev_thinking + config.default_thinking_effort = prev_effort console.print(f"[{_t.error}]Failed to save config: {_rich_escape(exc)}[/]") return @@ -335,11 +345,9 @@ async def model(app: Shell, args: str): if model_changed: track("model_switch", model=selected_model_name) if thinking_changed: - track("thinking_toggle", enabled=new_thinking) + track("thinking_toggle", enabled=new_thinking, level=new_effort) console.print( - f"[{_t.success}]Switched to {selected_display} " - f"with thinking {'on' if new_thinking else 'off'}. " - "Reloading...[/]" + f"[{_t.success}]Switched to {selected_display} with {thinking_label}. Reloading...[/]" ) # Pre-load LM Studio models so the user doesn't hit a 10-60s wait on @@ -1037,21 +1045,42 @@ async def thinking(app: Shell, args: str) -> None: if soul is None: return + from pythinker_code.thinking import ( + available_thinking_levels, + clamp_thinking_effort, + model_uses_native_thinking, + ) from pythinker_code.ui.shell.selectors.thinking import ThinkingLevel, run_thinking_selector from pythinker_code.ui.theme import get_tui_tokens as _get_tok_think _t_think = _get_tok_think() - curr_level: ThinkingLevel = "high" if soul.thinking else "off" + if soul.runtime.llm is None: + console.print(f"[{_t_think.error}]LLM is not set.[/]") + return + capabilities = soul.runtime.llm.capabilities + available_efforts = available_thinking_levels(capabilities) + if available_efforts == ("off",): + if model_uses_native_thinking(capabilities): + console.print( + f"[{_t_think.warning}]Current model uses native reasoning; " + "there is no effort setting to change.[/]" + ) + else: + console.print(f"[{_t_think.warning}]Current model does not support thinking.[/]") + return + + curr_effort = soul.thinking_effort or ("high" if soul.thinking else "off") + curr_level = clamp_thinking_effort(curr_effort, available_efforts) level = await run_thinking_selector( - current_level=curr_level, - available_levels=["off", "minimal", "low", "medium", "high", "xhigh"], + current_level=cast(ThinkingLevel, curr_level), + available_levels=[cast(ThinkingLevel, effort) for effort in available_efforts], ) if level is None: return new_thinking = level != "off" - if new_thinking == soul.thinking: + if level == curr_effort: console.print(f"[{_t_think.warning}]Thinking setting unchanged.[/]") return @@ -1066,6 +1095,7 @@ async def thinking(app: Shell, args: str) -> None: try: config_for_save = load_config(config_file) config_for_save.default_thinking = new_thinking + config_for_save.default_thinking_effort = level save_config(config_for_save, config_file) except (ConfigError, OSError) as exc: console.print(f"[{_t_think.error}]Failed to save config: {_rich_escape(exc)}[/]") @@ -1073,10 +1103,8 @@ async def thinking(app: Shell, args: str) -> None: from pythinker_code.telemetry import track - track("thinking_toggle", enabled=new_thinking) - console.print( - f"[{_t_think.success}]Thinking {'enabled' if new_thinking else 'disabled'}. Reloading...[/]" - ) + track("thinking_toggle", enabled=new_thinking, level=level) + console.print(f"[{_t_think.success}]Thinking level set to {level}. Reloading...[/]") raise Reload(session_id=soul.runtime.session.id) @@ -1215,7 +1243,24 @@ def print_settings_table() -> None: table.add_row("TUI style", get_active_tui_style()) table.add_row("Default model", config.default_model or "(none)") table.add_row("Telemetry", "on" if config.telemetry else "off") - table.add_row("Default thinking", "on" if config.default_thinking else "off") + from pythinker_code.llm import derive_model_capabilities + from pythinker_code.thinking import ( + effective_config_thinking_effort, + model_uses_native_thinking, + ) + + default_model_cfg = ( + config.models.get(config.default_model) if config.default_model else None + ) + default_thinking_label = ( + "native reasoning" + if default_model_cfg + and model_uses_native_thinking(derive_model_capabilities(default_model_cfg)) + else effective_config_thinking_effort( + config.default_thinking, config.default_thinking_effort + ) + ) + table.add_row("Default thinking", default_thinking_label) table.add_row("Show thinking stream", "on" if config.show_thinking_stream else "off") table.add_row("Turn recaps", "on" if config.tui.turn_recaps else "off") table.add_row("Default yolo", "on" if config.default_yolo else "off") diff --git a/src/pythinker_code/ui/shell/spacing.py b/src/pythinker_code/ui/shell/spacing.py index 3c66ca96..5bf0cf30 100644 --- a/src/pythinker_code/ui/shell/spacing.py +++ b/src/pythinker_code/ui/shell/spacing.py @@ -31,6 +31,7 @@ "TINTED_CARD_PADDING", "DIALOG_PANEL_PADDING", "WORKLOG_PANEL_PADDING", + "REPORT_PANEL_PADDING", "CODE_BLOCK_PADDING", "blank_row", "append_gap", @@ -45,12 +46,16 @@ #: Rows between semantic sections inside a panel/dialog. SECTION_GAP_ROWS: Final = 1 -#: ``(vertical, horizontal)`` padding for cards/panels. Vertical stays 0 so the -#: stream spacer is the only inter-block gap; horizontal gives the tint breathing room. +#: ``(vertical, horizontal)`` padding for compact cards/panels. Vertical stays 0 so +#: the stream spacer is the only inter-block gap; horizontal gives the tint breathing room. CARD_PADDING: Final = (0, 1) TINTED_CARD_PADDING: Final = (0, 1) DIALOG_PANEL_PADDING: Final = (0, 1) WORKLOG_PANEL_PADDING: Final = (0, 1) + +#: Long-form report/error cards are standalone reading surfaces. They get internal +#: vertical rhythm while still relying on the stream spacer for external gaps. +REPORT_PANEL_PADDING: Final = (1, 2) CODE_BLOCK_PADDING: Final = (0, 1) diff --git a/src/pythinker_code/ui/shell/tips.py b/src/pythinker_code/ui/shell/tips.py index d075b6a7..cb5952dc 100644 --- a/src/pythinker_code/ui/shell/tips.py +++ b/src/pythinker_code/ui/shell/tips.py @@ -8,7 +8,7 @@ #: Short feature hints surfaced while the agent is working. Keep each one #: actionable and tied to a real Pythinker feature / shortcut. FEATURE_TIPS: Final = ( - "Shift+Tab toggles plan mode for multi-step work", + "Shift+Tab changes thinking effort levels", "Subagents keep your main context clean", "/verify before declaring work done", "/learn captures a lesson after a correction", diff --git a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py index 573ce049..d740d692 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py +++ b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py @@ -7,6 +7,7 @@ from rich.console import Group, RenderableType from rich.text import Text +from pythinker_code.ui.shell.spacing import blank_row from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, ToolRenderDefinition, @@ -75,8 +76,10 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: style_token="error" if ctx.is_error else "success" if ctx.has_result else "muted", ) - children: list[RenderableType] = [header] - for q in qs[:2]: + children: list[RenderableType] = [header, blank_row()] + for index, q in enumerate(qs[:2]): + if index: + children.append(blank_row()) question_text = as_str(q.get("question")) or "" if question_text: children.append(fg("accent", f"? {question_text}")) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index b3dcf1bc..61382e85 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -160,6 +160,11 @@ def _find_committed_boundary(text: str) -> int | None: return markdown_commit_boundary(text) +def _starts_with_report_fence(text: str) -> bool: + stripped = text.lstrip().casefold() + return stripped.startswith(("```report", "~~~report")) + + def _tail_lines(text: str, n: int) -> str: """Extract the last *n* lines from *text* via reverse scanning (O(n)).""" pos = len(text) @@ -249,7 +254,10 @@ def compose_final(self) -> RenderableType: remaining = self._pending_text() if not remaining: return Text("") - return self._wrap_bullet(render_agent_body(remaining)) + rendered = self._wrap_bullet(render_agent_body(remaining)) + if self._has_printed_bullet and _starts_with_report_fence(remaining): + return Group(BLANK_ROW, rendered) + return rendered def has_pending(self) -> bool: """Whether there is uncommitted content to flush.""" @@ -300,6 +308,11 @@ def _flush_committed(self) -> None: if not self._has_printed_bullet: # Leading blank row separates this step from the previous block. console.print() + elif _starts_with_report_fence(committed_text): + # If prose streamed earlier and the next committed slice begins with + # a report fence, preserve the same one-row seam users get when the + # prose and report render together in a single markdown pass. + console.print() console.print(self._wrap_bullet(render_agent_body(committed_text))) self._committed_len += boundary diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 2440c8fd..0de734a9 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -24,6 +24,7 @@ from pythinker_code.ui.shell.console import console, render_to_ansi from pythinker_code.ui.shell.echo import render_user_echo_text from pythinker_code.ui.shell.keyboard import KeyEvent +from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.prompt import ( CustomPromptSession, UserInput, @@ -53,6 +54,9 @@ BtwRunner = Callable[[str, Callable[[str], None] | None], Awaitable[tuple[str | None, str | None]]] """async (question, on_text_chunk) -> (response, error). Used for direct btw execution.""" +_STATUS_REFRESH_INTERVAL_S = 0.22 +_STATUS_REFRESH_REDUCED_INTERVAL_S = 1.0 + class _PromptLiveView(_LiveView): """Interactive prompt view: renders agent output above the input buffer. @@ -97,6 +101,7 @@ def __init__( self._btw_dismiss_event: asyncio.Event | None = None self._btw_refresh_task: asyncio.Task[None] | None = None self._btw_run_task: asyncio.Task[None] | None = None + self._status_refresh_task: asyncio.Task[None] | None = None # -- Helpers ------------------------------------------------------------- @@ -164,7 +169,7 @@ def _on_chunk(chunk: str) -> None: self._prompt_session.invalidate() async def _btw_refresh_loop(self) -> None: - """Periodically invalidate prompt so the spinner animates.""" + """Periodically invalidate prompt so the btw modal spinner animates.""" try: while True: await asyncio.sleep(0.08) @@ -172,6 +177,27 @@ async def _btw_refresh_loop(self) -> None: except asyncio.CancelledError: pass + async def _status_refresh_loop(self) -> None: + """Periodically invalidate prompt so pinned status shimmer is frame-based. + + Wire events are bursty: a long-running subagent can leave the prompt + untouched for seconds, which freezes shimmer even though the turn is + still active. Keep this loop prompt-scoped and cheap; Rich Live mode has + its own refresh clock. + """ + try: + while True: + interval = ( + _STATUS_REFRESH_REDUCED_INTERVAL_S + if reduced_motion_enabled() + else _STATUS_REFRESH_INTERVAL_S + ) + await asyncio.sleep(interval) + if self._active_turn_depth > 0 and not self._turn_ended: + self._prompt_session.invalidate() + except asyncio.CancelledError: + pass + # -- Public API: queued messages for the shell to drain ------------------ def drain_queued_messages(self) -> list[UserInput]: @@ -207,9 +233,12 @@ async def visualize_loop(self, wire: WireUISide): # Declare outside try so finally can always cancel them. wire_task: asyncio.Task[WireMessage] | None = None external_task: asyncio.Task[WireMessage] | None = None + status_refresh_task: asyncio.Task[None] | None = None try: wire_task = asyncio.create_task(wire.receive()) external_task = asyncio.create_task(self._external_messages.get()) + status_refresh_task = asyncio.create_task(self._status_refresh_loop()) + self._status_refresh_task = status_refresh_task while True: try: done, _ = await asyncio.wait( @@ -256,12 +285,13 @@ async def visualize_loop(self, wire: WireUISide): # returns, because run_soul gives ui_task only a 0.5s timeout. finally: self._external_messages.shutdown(immediate=True) - for task in (wire_task, external_task): + for task in (wire_task, external_task, status_refresh_task): if task is None: continue task.cancel() with suppress(asyncio.CancelledError, QueueShutDown): await task + self._status_refresh_task = None self._pending_local_steer_count = 0 # Do NOT dismiss btw here — the shell will call # wait_for_btw_dismiss() after visualize_loop returns. diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index b7bccd2a..f464a1d9 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -27,7 +27,7 @@ from pythinker_code.session_recap import build_turn_recap_line from pythinker_code.soul import format_token_count -from pythinker_code.tools.display import TodoDisplayBlock, TodoDisplayItem +from pythinker_code.tools.display import DiffDisplayBlock, TodoDisplayBlock, TodoDisplayItem from pythinker_code.ui.shell.components.render_utils import ( cell_width, sanitize_ansi, @@ -43,6 +43,7 @@ active_marker_frame, activity_status_line, reduced_motion_enabled, + shimmer_text, ) from pythinker_code.ui.shell.spacing import BLANK_ROW from pythinker_code.ui.shell.spinner_words import spinner_message @@ -137,6 +138,12 @@ def _append_action_block( blocks.append(block) +def _print_action_block(block: RenderableType) -> None: + """Commit a completed action block to scrollback with one leading blank row.""" + console.print() + console.print(block) + + def _format_step_retry(retry: StepRetry) -> Text: reason = _step_retry_reason(retry) wait = format_elapsed(retry.wait_s) @@ -208,6 +215,7 @@ def __init__( self._recap_user_input = "" self._recap_text_parts: list[str] = [] self._recap_tool_counts: Counter[str] = Counter() + self._recap_files_modified: set[str] = set() self._pending_turn_recap = False self._current_content_block: _ContentBlock | None = None @@ -505,7 +513,10 @@ def compose_agent_output( if self._mcp_loading_spinner is not None: _append_action_block(blocks, self._mcp_loading_spinner) elif self._compaction_block is not None: - _append_action_block(blocks, self._compaction_block) + # Compaction starts after prior assistant/tool text has usually just + # committed to scrollback. Keep the same one-row seam the rest of + # the live stream uses instead of letting it touch the previous line. + _append_action_block(blocks, self._compaction_block, leading=True) else: current_step_retry = getattr(self, "_current_step_retry", None) if current_step_retry is not None: @@ -543,18 +554,30 @@ def compose_agent_output( _append_action_block(blocks, notification.compose()) return blocks + def _track_recap_modified_files(self, result: ToolResult) -> None: + """Record files a tool reported changing, for the turn-recap deltas.""" + for block in getattr(result.return_value, "display", []) or []: + if isinstance(block, DiffDisplayBlock) and block.path: + self._recap_files_modified.add(block.path) + def _print_turn_recap(self) -> None: if not self._show_turn_recaps: return - assistant_text = "\n".join(self._recap_text_parts).strip() + # TextPart values are streaming deltas, not paragraphs. Concatenate them + # directly; joining with spaces/newlines can split BPE-sized chunks into + # unreadable recap text such as `. py think er /re ports ...`. + assistant_text = "".join(self._recap_text_parts).strip() line = build_turn_recap_line( request=self._recap_user_input, assistant_text=assistant_text, step_count=sum(self._recap_tool_counts.values()) or None, + files_changed=len(self._recap_files_modified), ) if not line: return + console.print() console.print(Text(sanitize_ansi(line), style=tui_rich_style("muted") + Style(italic=True))) + console.print() def _working_indicator(self) -> RenderableType: now = time.monotonic() @@ -605,9 +628,15 @@ def _todo_activity_line(self, label: str, *, elapsed_s: float, width: int) -> Te suffix = f" {metadata}" label_width = max(1, width - cell_width(prefix) - cell_width(suffix)) - accent = tui_rich_style("warning") - line = Text(prefix, style=accent) - line.append(truncate_to_width(label, label_width), style=accent) + label_text = truncate_to_width(label, label_width) + line = Text(prefix, style=tui_rich_style("thinking_text")) + line.append_text( + shimmer_text( + label_text, + elapsed_s, + reduced_motion=reduced_motion_enabled(), + ) + ) line.append(suffix, style=tui_rich_style("muted")) return line @@ -684,11 +713,11 @@ def _pinned_todo_row( ) -> Text: if todo.status == "done": icon = "✓" - icon_token = "success" + icon_token = "muted" title_style = tui_rich_style("muted") + Style(strike=True) elif todo.status == "in_progress": icon = "■" - icon_token = None + icon_token = "warning" title_style = tui_rich_style("activity_label") + Style(bold=True) else: icon = "□" @@ -700,14 +729,6 @@ def _pinned_todo_row( title_budget = max(1, width - cell_width(prefix) - 2) title = truncate_to_width(todo.title.strip(), title_budget) row = Text(prefix, style=tui_rich_style("muted")) - if todo.status == "in_progress": - row.append(icon, style=tui_rich_style("warning")) - row.append(" ") - row.append(title, style=title_style) - return row - # icon_token is only None on the in_progress branch above, which - # early-returned. Narrow for pyright with an explicit assert. - assert icon_token is not None row.append(icon, style=tui_rich_style(icon_token)) row.append(" ") row.append(title, style=title_style) @@ -774,6 +795,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: ) self._recap_text_parts.clear() self._recap_tool_counts.clear() + self._recap_files_modified.clear() self._pending_turn_recap = False self._active_turn_depth += 1 self.flush_content() @@ -828,7 +850,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: truncated_q = (q[:50] + "...") if len(q) > 50 else q self._btw_question = None if response: - console.print( + _print_action_block( Panel( Markdown(response), title=f"[dim]btw: {rich_escape(truncated_q)}[/dim]", @@ -838,7 +860,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: ) ) elif error: - console.print( + _print_action_block( Panel( Text(error, style=tui_rich_style("error")), title="[dim]btw (error)[/dim]", @@ -882,6 +904,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: case ToolOutputPart(): self.append_tool_output_part(msg) case ToolResult(): + self._track_recap_modified_files(msg) self.append_tool_result(msg) case ApprovalResponse(): self._reconcile_approval_requests() @@ -1043,8 +1066,7 @@ def cleanup(self, is_interrupt: bool) -> None: for tool_call_id in list(self._tool_call_blocks.keys()): block = self._tool_call_blocks.pop(tool_call_id) self._archive_completed_tool_card(block) - console.print() - console.print(block.compose()) + _print_action_block(block.compose()) self.refresh_soon() self.flush_notifications() if not is_interrupt and self._active_turn_depth == 0 and self._pending_turn_recap: @@ -1115,8 +1137,7 @@ def flush_finished_tool_calls(self) -> None: self._archive_completed_tool_card(block) self._tool_call_blocks.pop(tool_call_id) - console.print() - console.print(block.compose()) + _print_action_block(block.compose()) if self._last_tool_call_block == block: self._last_tool_call_block = None self.refresh_soon() @@ -1125,8 +1146,7 @@ def flush_notifications(self) -> None: """Flush rendered notifications to terminal history.""" self._live_notification_blocks.clear() while self._notification_blocks: - console.print() - console.print(self._notification_blocks.popleft().compose()) + _print_action_block(self._notification_blocks.popleft().compose()) self.refresh_soon() def append_content(self, part: ContentPart) -> None: @@ -1230,23 +1250,20 @@ def append_hook_resolved(self, event: HookResolved) -> None: ) ) block.resolve(event) - console.print() - console.print(block.compose()) + _print_action_block(block.compose()) self.refresh_soon() def display_question_answered(self, event: QuestionAnswered) -> None: self.flush_content() block = _QuestionAnsweredBlock(event) - console.print() - console.print(block.compose()) + _print_action_block(block.compose()) self.refresh_soon() def display_progress_note(self, event: ProgressNote) -> None: self.flush_content() self.flush_finished_tool_calls() block = _ProgressNoteBlock(event) - console.print() - console.print(block.compose()) + _print_action_block(block.compose()) self.refresh_soon() def request_approval(self, request: ApprovalRequest) -> None: @@ -1305,7 +1322,7 @@ def display_plan(self, msg: PlanDisplay) -> None: subtitle=msg.file_path, border_style=tui_rich_style("border"), ) - console.print(panel) + _print_action_block(panel) def request_question(self, request: QuestionRequest) -> None: self._question_request_queue.append(request) diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index 55b0a59f..75d0a910 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -20,7 +20,7 @@ from pythinker_code.ui.shell.design_system import ShellTone, StatusName, shell_style, status_icon from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled -from pythinker_code.ui.shell.spacing import WORKLOG_PANEL_PADDING +from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING, WORKLOG_PANEL_PADDING from pythinker_code.ui.theme import get_tui_tokens, tui_rich_style from pythinker_code.utils.rich.columns import BulletColumns from pythinker_code.utils.rich.diff_render import ( @@ -170,6 +170,7 @@ def render_worklog_card( *, subtitle: str | None = None, border_style: StyleType = "grey39", + padding: tuple[int, int] = WORKLOG_PANEL_PADDING, ) -> Panel: return Panel( body, @@ -179,7 +180,7 @@ def render_worklog_card( subtitle_align="left", border_style=border_style, box=box.ROUNDED, - padding=WORKLOG_PANEL_PADDING, + padding=padding, expand=False, ) @@ -236,6 +237,7 @@ def render_display_blocks( border_style=tui_rich_style("error") if is_error else tui_rich_style("dim"), + padding=REPORT_PANEL_PADDING, ) ) else: diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index 64ff90f4..b62a8bc6 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -565,3 +565,46 @@ def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: if token.endswith("_bg"): return RichStyle(bgcolor=value) return RichStyle(color=value) + + +# --------------------------------------------------------------------------- +# Thinking-level prompt frame colors (Shift+Tab cycle). Keyed by the plain level +# string to avoid a theme<->selector import cycle; ThinkingLevel values are +# exactly these strings. +# --------------------------------------------------------------------------- + +_THINKING_FRAME_DARK: dict[str, str] = { + # Cool-to-warm ramp: quiet slate when disabled, then increasingly vivid + # thinking states as the model spends more effort. + "off": "#94A3B8", # slate + "minimal": "#60A5FA", # blue + "low": "#22D3EE", # cyan + "medium": "#34D399", # emerald + "high": "#FBBF24", # amber + "xhigh": "#FB7185", # rose + "max": "#F472B6", # pink +} + +_THINKING_FRAME_LIGHT: dict[str, str] = { + "off": "#475569", # slate + "minimal": "#0369A1", # blue + "low": "#0E7490", # cyan + "medium": "#047857", # emerald + "high": "#92400E", # amber + "xhigh": "#9F1239", # rose + "max": "#9D174D", # pink +} + + +def thinking_frame_color(level: str, *, theme: ThemeName | None = None) -> str: + """Hex frame color for thinking *level*; unmapped levels fall back to ``border``.""" + name = theme if theme is not None else _active_theme + table = _THINKING_FRAME_LIGHT if name == "light" else _THINKING_FRAME_DARK + return table.get(level) or get_tui_tokens(theme).border + + +def thinking_frame_style(level: str, *, theme: ThemeName | None = None) -> str: + """prompt_toolkit frame style for *level* (``"fg:#A78BFA"``), or ``""`` when colors are off.""" + if colors_disabled(): + return "" + return f"fg:{thinking_frame_color(level, theme=theme)}" diff --git a/src/pythinker_code/web/api/config.py b/src/pythinker_code/web/api/config.py index dc90275f..187d6c82 100644 --- a/src/pythinker_code/web/api/config.py +++ b/src/pythinker_code/web/api/config.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field +from pythinker_core.chat_provider import ThinkingEffort from pythinker_code.config import LLMModel, get_config_file, load_config, save_config from pythinker_code.llm import ProviderType, derive_model_capabilities @@ -33,6 +34,10 @@ class GlobalConfig(BaseModel): default_model: str = Field(description="Current default model key") default_thinking: bool = Field(description="Current default thinking mode") + default_thinking_effort: ThinkingEffort | None = Field( + default=None, + description="Current default thinking effort level", + ) models: list[ConfigModel] = Field(description="All configured models") @@ -41,6 +46,10 @@ class UpdateGlobalConfigRequest(BaseModel): default_model: str | None = Field(default=None, description="New default model key") default_thinking: bool | None = Field(default=None, description="New default thinking mode") + default_thinking_effort: ThinkingEffort | None = Field( + default=None, + description="New default thinking effort level", + ) restart_running_sessions: bool | None = Field( default=None, description="Whether to restart running sessions" ) @@ -109,6 +118,7 @@ def _build_global_config() -> GlobalConfig: return GlobalConfig( default_model=config.default_model, default_thinking=config.default_thinking, + default_thinking_effort=config.default_thinking_effort, models=models, ) @@ -152,9 +162,15 @@ async def update_global_config( ) config.default_model = request.default_model - # Update default_thinking - if request.default_thinking is not None: + # Update thinking. The effort field is the source of truth: when provided + # (including an explicit "off") it wins, and default_thinking is derived from + # it. Otherwise fall back to the legacy bool and derive a sensible effort. + if request.default_thinking_effort is not None: + config.default_thinking_effort = request.default_thinking_effort + config.default_thinking = request.default_thinking_effort != "off" + elif request.default_thinking is not None: config.default_thinking = request.default_thinking + config.default_thinking_effort = "high" if request.default_thinking else "off" # Save config save_config(config) diff --git a/tests/auth/test_minimax_auth.py b/tests/auth/test_minimax_auth.py index 5bcdac18..c4b10a28 100644 --- a/tests/auth/test_minimax_auth.py +++ b/tests/auth/test_minimax_auth.py @@ -29,6 +29,7 @@ def test_minimax_model_catalog_contains_four_current_models(): } assert all(m.provider_key == "managed:minimax-anthropic" for m in MINIMAX_MODELS) + assert all(m.capabilities == {"always_thinking"} for m in MINIMAX_MODELS) def test_minimax_env_key_uses_minimax_api_key(monkeypatch): @@ -62,6 +63,7 @@ def test_apply_minimax_config_writes_provider_and_default(): assert provider.api_key.get_secret_value() == "mx-test" assert config.models["minimax/m2.7"].provider == MINIMAX_ANTHROPIC_PROVIDER_KEY assert config.models["minimax/m2.7"].model == "MiniMax-M2.7" + assert config.models["minimax/m2.7"].capabilities == {"always_thinking"} assert config.default_model == "minimax/m2.7" @@ -195,6 +197,7 @@ async def fake_discover(api_key): assert events[-1].type == "success" assert config.models["minimax/m2.7"].max_context_size == 512_000 + assert config.models["minimax/m2.7"].capabilities == {"always_thinking"} @pytest.mark.asyncio diff --git a/tests/auth/test_openai_auth.py b/tests/auth/test_openai_auth.py index 24e95f57..f55d4d0e 100644 --- a/tests/auth/test_openai_auth.py +++ b/tests/auth/test_openai_auth.py @@ -3,6 +3,7 @@ import asyncio import base64 import json +from pathlib import Path from urllib.parse import parse_qs, urlsplit import aiohttp @@ -112,6 +113,47 @@ def test_openai_callback_html_uses_pythinker_branding(): assert f'' in page +def test_committed_brand_assets_match_web_public_source(): + """Committed login-brand assets must stay byte-identical to their canonical source. + + ``web/static/`` is a build output: ``scripts/build_web.py`` ``rmtree``s it and + repopulates from the vite build (whose brand files come from ``web/public/brand``). + We force-commit ``icon.svg`` + ``favicon.ico`` only so the test job — which does + not run the web build — still has the files the OAuth callback reads. This guard + fails loudly if the canonical source changes without the committed copies being + re-synced (otherwise the branding tests above would validate a stale fixture). + """ + from pythinker_code.auth.browser_login_page import ( + _PYTHINKER_FAVICON_PATH, + _PYTHINKER_LOGO_PATH, + ) + + public_brand = Path(__file__).resolve().parents[2] / "web" / "public" / "brand" + for committed, name in ( + (_PYTHINKER_LOGO_PATH, "icon.svg"), + (_PYTHINKER_FAVICON_PATH, "favicon.ico"), + ): + source = public_brand / name + assert source.exists(), f"canonical brand source missing: {source}" + assert committed.read_bytes() == source.read_bytes(), ( + f"{committed} drifted from canonical source {source}; re-sync the committed " + "copy (e.g. `cp` from web/public/brand or rerun the web build) so the " + "login-branding tests match what actually ships." + ) + + +def test_browser_login_asset_missing_degrades_gracefully(tmp_path): + """A missing brand asset must not break the OAuth callback page. + + The data-uri helper should log and return an empty source rather than raising, + so login still completes if a build ever ships without the cosmetic assets. + """ + from pythinker_code.auth.browser_login_page import browser_login_asset_data_uri + + missing = tmp_path / "does-not-exist.svg" + assert browser_login_asset_data_uri(missing, "image/svg+xml") == "" + + def test_openai_callback_html_escapes_error_message(): page = _callback_html(ok=False, message='') diff --git a/tests/auth/test_opencode_go_auth.py b/tests/auth/test_opencode_go_auth.py index 7b93d87b..d4c119ae 100644 --- a/tests/auth/test_opencode_go_auth.py +++ b/tests/auth/test_opencode_go_auth.py @@ -104,7 +104,10 @@ def test_apply_opencode_go_config_writes_two_providers_and_default(): assert openai_provider.api_key.get_secret_value() == "ocgo-test" assert anthropic_provider.api_key.get_secret_value() == "ocgo-test" assert config.models["opencode-go/kimi-k2.6"].provider == OPENCODE_GO_OPENAI_PROVIDER_KEY + assert config.models["opencode-go/kimi-k2.6"].capabilities is None + assert config.models["opencode-go/glm-5"].capabilities == {"always_thinking"} assert config.models["opencode-go/minimax-m2.7"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY + assert config.models["opencode-go/minimax-m2.7"].capabilities == {"always_thinking"} assert config.default_model == "opencode-go/kimi-k2.6" @@ -329,6 +332,14 @@ def test_build_models_falls_back_to_catalog_then_heuristic_without_metadata(): assert by_id["minimax-m9.9"].provider_key == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY +def test_native_thinking_capabilities_cover_glm_and_minimax_only(): + from pythinker_code.auth.opencode_go import _native_thinking_capabilities + + assert _native_thinking_capabilities("glm-5") == {"always_thinking"} + assert _native_thinking_capabilities("minimax-m2.7") == {"always_thinking"} + assert _native_thinking_capabilities("kimi-k2.6") is None + + def test_parse_models_dev_metadata_extracts_name_context_and_shape(): from pythinker_code.auth.opencode_go import _ModelsDevMeta, _parse_models_dev_metadata @@ -501,6 +512,7 @@ def test_apply_opencode_go_models_adds_new_and_corrects_shape_preserving_user_pr # New model now present on the Anthropic-shaped provider. assert config.models["opencode-go/qwen3.7-max"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY assert config.models["opencode-go/qwen3.7-max"].max_context_size == 1_000_000 + assert config.models["opencode-go/qwen3.7-max"].capabilities is None # Existing Qwen corrected from OpenAI → Anthropic shape. assert config.models["opencode-go/qwen3.5-plus"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY # User preferences untouched. diff --git a/tests/conftest.py b/tests/conftest.py index ee7d2eb6..09ce7615 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -258,13 +258,18 @@ def toolset() -> PythinkerToolset: @contextmanager -def tool_call_context(tool_name: str) -> Generator[None]: - """Create a tool call context.""" +def tool_call_context( + tool_name: str, arguments: dict[str, object] | None = None +) -> Generator[None]: + """Create a tool call context. Pass ``arguments`` to populate the call payload.""" + import json + from pythinker_code.soul.toolset import current_tool_call from pythinker_code.wire.types import ToolCall + encoded = json.dumps(arguments) if arguments is not None else None token = current_tool_call.set( - ToolCall(id="test", function=ToolCall.FunctionBody(name=tool_name, arguments=None)) + ToolCall(id="test", function=ToolCall.FunctionBody(name=tool_name, arguments=encoded)) ) try: yield diff --git a/tests/core/test_acp_thinking_effort.py b/tests/core/test_acp_thinking_effort.py new file mode 100644 index 00000000..e4aeb90c --- /dev/null +++ b/tests/core/test_acp_thinking_effort.py @@ -0,0 +1,39 @@ +"""Contract test for the ACP model-switch thinking-effort resolution (Finding 4). + +`AcpServer.set_model` derives `thinking_effort` from the selected model's +`thinking` flag. The regression was forcing `"high"` and discarding the user's +configured effort; the fix preserves the configured effort and only defaults to +high when thinking was previously off. This mirrors that logic against the shared +resolver so the contract is pinned even though the server method itself needs a +live ACP session to exercise end-to-end. +""" + +import pytest + +from pythinker_code.thinking import DEFAULT_THINKING_EFFORT, effective_config_thinking_effort + + +def _resolve_acp_effort(thinking: bool, default_thinking: bool, default_effort: str | None) -> str: + if thinking: + current = effective_config_thinking_effort(default_thinking, default_effort) # type: ignore[arg-type] + return current if current != "off" else DEFAULT_THINKING_EFFORT + return "off" + + +@pytest.mark.parametrize( + "default_thinking,default_effort,expected", + [ + (True, "medium", "medium"), # preserve configured medium + (True, "low", "low"), # preserve configured low + (False, "off", "high"), # was off -> default to high when model wants thinking + (True, None, "high"), # legacy bool true, no effort -> high + ], +) +def test_acp_thinking_model_preserves_effort( + default_thinking: bool, default_effort: str | None, expected: str +) -> None: + assert _resolve_acp_effort(True, default_thinking, default_effort) == expected + + +def test_acp_non_thinking_model_is_off() -> None: + assert _resolve_acp_effort(False, True, "high") == "off" diff --git a/tests/core/test_approval_auto.py b/tests/core/test_approval_auto.py index b82ab544..c62f5d11 100644 --- a/tests/core/test_approval_auto.py +++ b/tests/core/test_approval_auto.py @@ -2,7 +2,17 @@ from __future__ import annotations +import json + from pythinker_code.soul.approval import Approval, ApprovalState +from pythinker_code.wire.types import ToolCall + + +def _shell_call(cmd: str) -> ToolCall: + return ToolCall( + id="call-1", + function=ToolCall.FunctionBody(name="Shell", arguments=json.dumps({"command": cmd})), + ) def test_yolo_only() -> None: @@ -104,3 +114,68 @@ def test_set_auto_false_clears_runtime_auto() -> None: approval.set_auto(False) assert approval.is_auto() is False assert approval.is_runtime_auto() is False + + +def test_destructive_action_deliberates_once_then_proceeds_under_auto() -> None: + """auto + auto_deliberate: a destructive Shell command deliberates the first + time, the identical re-issue runs once (one-shot retry), and a third issue + deliberates again — so deliberation never permanently whitelists ``rm -rf``.""" + approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) + + first = approval.deliberation_gate(_shell_call("rm -rf build")) + assert first is not None, "first destructive issue should deliberate" + + second = approval.deliberation_gate(_shell_call("rm -rf build")) + assert second is None, "identical re-issue is the one-shot retry: allowed through" + + third = approval.deliberation_gate(_shell_call("rm -rf build")) + assert third is not None, "one-shot consumed; a fresh issue deliberates again" + + +def test_deliberation_gate_conditions() -> None: + """The gate fires only when the feature is on, we would otherwise auto-approve + (auto OR yolo), and the command is destructive.""" + rm = _shell_call("rm -rf x") + safe = _shell_call("ls -la") + + # feature off -> never deliberates (full back-compat) + off = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) + assert off.deliberation_gate(rm) is None + + # human present (not auto, not yolo) -> normal interactive approval shows the rm -rf; + # no self-deliberation needed + human = Approval(state=ApprovalState(auto=False, auto_deliberate=True)) + assert human.deliberation_gate(rm) is None + + # yolo + auto_deliberate -> gates AHEAD of the yolo bypass + yolo = Approval(state=ApprovalState(yolo=True, auto_deliberate=True)) + assert yolo.deliberation_gate(rm) is not None + + # non-destructive in auto + auto_deliberate -> proceeds untouched + benign = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) + assert benign.deliberation_gate(safe) is None + + +async def test_request_bounces_destructive_then_approves_retry() -> None: + """End-to-end through request(): a destructive command in auto + auto_deliberate is + bounced once with deliberation feedback that does NOT masquerade as a user rejection, + then the identical retry auto-approves.""" + from tests.conftest import tool_call_context + + approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) + with tool_call_context("Shell", arguments={"command": "rm -rf build"}): + first = await approval.request("Shell", "run command", "Run command `rm -rf build`") + assert not first, "destructive action is bounced for deliberation" + assert first.deliberation is True + assert "irreversible" in first.feedback + assert "rejected by the user" not in first.rejection_error().message + + second = await approval.request("Shell", "run command", "Run command `rm -rf build`") + assert second, "one-shot consumed: the deliberated retry runs" + + +def test_approval_state_honors_auto_deliberate_flag() -> None: + on = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) + assert on.deliberation_gate(_shell_call("rm -rf build")) is not None + off = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) + assert off.deliberation_gate(_shell_call("rm -rf build")) is None diff --git a/tests/core/test_auto_injection.py b/tests/core/test_auto_injection.py index 3f9d5087..7fb62813 100644 --- a/tests/core/test_auto_injection.py +++ b/tests/core/test_auto_injection.py @@ -7,6 +7,7 @@ from pythinker_code.soul.dynamic_injections.auto_mode import ( _AUTO_INJECTION_TYPE, _AUTO_PROMPT, + _AUTO_PROMPT_DELIBERATE, AutoModeInjectionProvider, ) @@ -17,6 +18,7 @@ def _mock_soul( is_yolo: bool = False, is_subagent: bool = False, has_ask_user: bool = True, + ask_user_question_policy: str = "ask_except_auto", ) -> MagicMock: soul = MagicMock() soul.is_auto = is_auto @@ -24,6 +26,7 @@ def _mock_soul( soul.is_yolo = is_yolo soul.is_subagent = is_subagent soul.has_tool.return_value = has_ask_user + soul.runtime.config.ask_user_question_policy = ask_user_question_policy return soul @@ -37,6 +40,15 @@ async def test_injects_when_auto_enabled() -> None: assert "Do NOT call AskUserQuestion" in result[0].content +async def test_injects_deliberate_prompt_under_auto_deliberate_policy() -> None: + provider = AutoModeInjectionProvider() + soul = _mock_soul(is_auto=True, ask_user_question_policy="auto_deliberate") + result = await provider.get_injections([], soul) + assert len(result) == 1 + assert result[0].content == _AUTO_PROMPT_DELIBERATE + assert "advisor-assisted self-decision" in result[0].content + + async def test_runtime_auto_does_not_inject_prompt() -> None: provider = AutoModeInjectionProvider() result = await provider.get_injections([], _mock_soul(is_auto=True, is_auto_flag=False)) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 901d8841..cc5f6705 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -23,6 +23,7 @@ def test_default_config_dump(): { "default_model": "", "default_thinking": False, + "default_thinking_effort": None, "agent_execution_profile": "default", "default_yolo": False, "ask_user_question_policy": "ask_except_auto", @@ -241,3 +242,8 @@ def test_load_config_compaction_trigger_ratio_too_low(): def test_load_config_compaction_trigger_ratio_too_high(): with pytest.raises(ConfigError, match="compaction_trigger_ratio"): load_config_from_string('{"loop_control": {"compaction_trigger_ratio": 1.0}}') + + +def test_auto_deliberate_is_a_valid_policy() -> None: + c = Config(ask_user_question_policy="auto_deliberate") + assert c.ask_user_question_policy == "auto_deliberate" diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 2f631194..0cb2eb13 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -457,6 +457,30 @@ def test_create_llm_openai_legacy_thinking_false_keeps_off_for_reasoning_model() assert "reasoning_effort" in llm.chat_provider.model_parameters +def test_create_llm_openai_legacy_thinking_effort_xhigh_is_preserved(): + """Explicit effort levels should reach the provider instead of collapsing + every enabled thinking request to high.""" + provider = LLMProvider( + type="openai_legacy", + base_url="https://api.example.com/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="managed:example", + model="example-pro-flash", + max_context_size=128000, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking_effort="xhigh") + + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.chat_provider.model_parameters.get("reasoning_effort") == "xhigh" + assert llm.thinking is True + assert llm.thinking_effort == "xhigh" + + def _make_pythinker_thinking_model() -> tuple[LLMProvider, LLMModel]: """Helper: build a pythinker provider + always-thinking model pair.""" provider = LLMProvider( @@ -608,6 +632,34 @@ def test_create_llm_pythinker_thinking_keep_injected_on_explicit_thinking_true(m ) +def test_derive_model_capabilities_preserves_native_thinking_without_effort_dial(): + model = LLMModel( + provider="managed:minimax-anthropic", + model="MiniMax-M2.7", + max_context_size=192_000, + capabilities={"always_thinking"}, + ) + + assert derive_model_capabilities(model) == {"always_thinking"} + + +def test_create_llm_native_thinking_model_uses_active_internal_default(): + provider = LLMProvider(type="_echo", base_url="", api_key=SecretStr("")) + model = LLMModel( + provider="_echo", + model="native-model", + max_context_size=1234, + capabilities={"always_thinking"}, + ) + + llm = create_llm(provider, model, thinking=False) + + assert llm is not None + assert llm.capabilities == {"always_thinking"} + assert llm.thinking is True + assert llm.thinking_effort == "high" + + def test_derive_model_capabilities_marks_kimi_k2_as_toggleable_thinking(): model = LLMModel( provider="openai-compatible", diff --git a/tests/core/test_deliberation_advisor.py b/tests/core/test_deliberation_advisor.py new file mode 100644 index 00000000..ebde23db --- /dev/null +++ b/tests/core/test_deliberation_advisor.py @@ -0,0 +1,29 @@ +from pythinker_code.soul.deliberation import ( + _format_questions_for_advisor, + _strip_recommended, +) +from pythinker_code.wire.types import QuestionItem, QuestionOption + + +def test_strip_recommended_marker() -> None: + assert _strip_recommended("Use Postgres (Recommended)") == "Use Postgres" + assert _strip_recommended("Use Postgres") == "Use Postgres" + assert _strip_recommended("Keep (recommended)") == "Keep" + + +def test_format_questions_is_blind() -> None: + q = QuestionItem( + question="Which DB?", + header="DB", + options=[ + QuestionOption(label="Postgres (Recommended)", description="solid"), + QuestionOption(label="SQLite", description="simple"), + ], + multi_select=False, + ) + text = _format_questions_for_advisor([q]) + assert "Which DB?" in text + assert "Postgres" in text and "SQLite" in text + # blind-first: the favored marker must not leak to the advisor + assert "(Recommended)" not in text + assert "recommended" not in text.lower() diff --git a/tests/core/test_subagent_builder.py b/tests/core/test_subagent_builder.py index f6f259d2..2c0d5f99 100644 --- a/tests/core/test_subagent_builder.py +++ b/tests/core/test_subagent_builder.py @@ -129,7 +129,7 @@ async def test_builder_model_priority_prefers_override_then_type_default_then_in captured_thinking: list[bool | None] = [] def fake_clone_llm_with_model_alias( - llm, config, model_alias, *, session_id, oauth, thinking=None + llm, config, model_alias, *, session_id, oauth, thinking=None, thinking_effort=None ): captured_aliases.append(model_alias) captured_thinking.append(thinking) diff --git a/tests/core/test_thinking.py b/tests/core/test_thinking.py new file mode 100644 index 00000000..c0146430 --- /dev/null +++ b/tests/core/test_thinking.py @@ -0,0 +1,70 @@ +from pythinker_code.config import Config +from pythinker_code.thinking import ( + apply_login_thinking_defaults, + available_thinking_levels, + effective_config_thinking_effort, + model_uses_native_thinking, + next_thinking_level, +) + + +def test_login_thinking_defaults_initialize_when_unset(): + config = Config() # fresh config: default_thinking_effort is None + apply_login_thinking_defaults(config, thinking=False, effort="off") + assert config.default_thinking is False + assert config.default_thinking_effort == "off" + + +def test_login_thinking_defaults_preserve_explicit_user_choice(): + config = Config(default_thinking=True, default_thinking_effort="low") + # A re-login into a non-thinking provider must not clobber the user's pick; + # create_llm clamps an unsupported level at use-time. + apply_login_thinking_defaults(config, thinking=False, effort="off") + assert config.default_thinking is True + assert config.default_thinking_effort == "low" + + +def test_explicit_off_overrides_legacy_default_thinking_true(): + # explicit effort wins, even over the legacy bool + assert effective_config_thinking_effort(True, "off") == "off" + + +def test_effort_wins_when_set_and_bool_fallback_when_none(): + assert effective_config_thinking_effort(True, "medium") == "medium" + assert effective_config_thinking_effort(False, "medium") == "medium" + assert effective_config_thinking_effort(True, None) == "high" + assert effective_config_thinking_effort(False, None) == "off" + + +def test_resolver_matches_legacy_or_expression_for_reachable_states(): + # reachable states (writers keep effort<->bool in sync); resolver == old `or` expr + assert effective_config_thinking_effort(True, "high") == "high" + assert effective_config_thinking_effort(False, "off") == "off" + assert effective_config_thinking_effort(True, None) == "high" + assert effective_config_thinking_effort(False, None) == "off" + + +def test_cycle_from_off_on_always_thinking_lands_on_minimal(): + levels = available_thinking_levels({"thinking", "always_thinking"}) + assert "off" not in levels + # off is unselectable here; the next press must land on the lowest valid level + assert next_thinking_level("off", levels) == "minimal" + + +def test_cycle_advances_normally_from_valid_level(): + levels = available_thinking_levels({"thinking", "always_thinking"}) + assert next_thinking_level("minimal", levels) == "low" + + +def test_native_thinking_capability_has_no_user_effort_dial(): + capabilities = {"always_thinking"} + + assert model_uses_native_thinking(capabilities) + assert available_thinking_levels(capabilities) == ("off",) + + +def test_always_thinking_with_effort_capability_remains_selectable(): + capabilities = {"thinking", "always_thinking"} + + assert not model_uses_native_thinking(capabilities) + assert "off" not in available_thinking_levels(capabilities) diff --git a/tests/test_session_recap.py b/tests/test_session_recap.py index 2e8a90a1..7c887f87 100644 --- a/tests/test_session_recap.py +++ b/tests/test_session_recap.py @@ -17,6 +17,7 @@ _format_duration, _format_tool_counts, _last_substantive_thread, + _outcome_sentence, _tool_label, build_turn_recap_line, format_recap, @@ -65,11 +66,98 @@ def test_build_turn_recap_line_uses_assistant_text() -> None: ) assert line == ( - "※ recap: Implemented a /recap command and a shell recap banner. (3 steps) " + "※ recap: Implemented a /recap command and a shell recap banner. · 3 steps " "(disable recaps in /settings)" ) +def test_build_turn_recap_line_strips_report_blocks() -> None: + line = build_turn_recap_line( + request="run deep scan", + assistant_text=( + "Deep scan completed. Full report saved here:\n" + " `.pythinker/reports/deep-code-scan-pr-auto-mode-deliberation-clean.md`\n" + "```report\n" + '{"title": "Deep Code Scan Results", "findings": []}\n' + "```\n" + ), + step_count=28, + ) + + assert line == ("※ recap: Deep scan completed. · 28 steps (disable recaps in /settings)") + + +def test_build_turn_recap_line_prefers_closing_summary_over_opening_intent() -> None: + # The opening sentence is pure intent; the recap should surface the outcome. + line = build_turn_recap_line( + request="run a deep scan", + assistant_text=( + "I'll start by gathering the current state of the repository before " + "kicking off a fresh deep scan. " + "I read every existing report and traced the imports. " + "Generated three fresh report files and refreshed the scan index." + ), + step_count=67, + files_changed=4, + ) + + assert line == ( + "※ recap: Generated three fresh report files and refreshed the scan index. " + "· 4 files changed · 67 steps (disable recaps in /settings)" + ) + + +def test_build_turn_recap_line_skips_trailing_question() -> None: + line = build_turn_recap_line( + request="refactor auth", + assistant_text=( + "Refactored the auth module and added regression tests. " + "Want me to also update the docs?" + ), + step_count=5, + ) + + assert line == ( + "※ recap: Refactored the auth module and added regression tests. · 5 steps " + "(disable recaps in /settings)" + ) + + +def test_build_turn_recap_line_singular_deltas() -> None: + line = build_turn_recap_line( + request="tweak", + assistant_text="Adjusted the spinner interval to feel calmer on slow terminals.", + step_count=1, + files_changed=1, + ) + + assert line == ( + "※ recap: Adjusted the spinner interval to feel calmer on slow terminals. " + "· 1 file changed · 1 step (disable recaps in /settings)" + ) + + +def test_outcome_sentence_picks_last_substantive_declarative() -> None: + # Skips intent opener and a path-dominated trailing line. + text = ( + "Let me trace the failure first. " + "Patched the off-by-one in the cursor math and added a guard. " + "See logs/very-long-trace-file-name-that-exceeds-the-token-limit.txt" + ) + assert _outcome_sentence(text) == ( + "Patched the off-by-one in the cursor math and added a guard." + ) + + +def test_outcome_sentence_skips_curly_apostrophe_intent() -> None: + # Models emit a typographic apostrophe; the trailing intent must still skip. + text = ( + "Patched the cursor math and added a regression guard. " + "Now I’ll run the full suite to confirm." + ) + assert _outcome_sentence(text) == "Patched the cursor math and added a regression guard." + + _UTC = ZoneInfo("UTC") _NOW = datetime(2026, 6, 1, 15, 30, tzinfo=_UTC) @@ -138,8 +226,9 @@ def test_tool_label_known_and_passthrough() -> None: def test_first_sentence_boundaries() -> None: - # Separator before index 40 is ignored; the whole string is kept. + # Very short separators are ignored; the whole string is kept. assert _first_sentence("Too short. Then more text here") == "Too short. Then more text here" + assert _first_sentence("Deep scan completed. Full report saved here") == "Deep scan completed." # No punctuation -> whole (collapsed) string. assert _first_sentence("a plain line with no terminator at all") == ( "a plain line with no terminator at all" @@ -159,6 +248,39 @@ def test_format_recap_empty_items() -> None: assert "No Pythinker sessions found" in out +def test_format_recap_leads_bullet_with_outcome_not_first_message() -> None: + item = SessionRecapItem(title="renderer work", session_id="s1") + item.start_ts, item.end_ts = 100.0, 100.0 + 90 * 60 # 90 min -> not a light day + item.turn_count = 6 + item.first_user_message = "the table rendering looks wrong" + item.last_user_message = "thanks" + item.assistant_snippets = [ + "Let me look at the renderer.", + "Repaired the markdown table pipeline and added a contract test.", + ] + + out = format_recap([item], parse_recap_range("today", now=_NOW)) + + assert "Repaired the markdown table pipeline and added a contract test." in out + assert "the table rendering looks wrong" not in out + + +def test_format_recap_light_day_is_reported_plainly() -> None: + item = SessionRecapItem(title="quick planning", session_id="s1") + item.start_ts, item.end_ts = 100.0, 100.0 + 11 * 60 # ~11 min + item.turn_count = 2 + item.first_user_message = "let's sketch the content automation system" + item.assistant_snippets = ["Outlined the pipeline stages and open questions."] + + out = format_recap([item], parse_recap_range("today", now=_NOW)) + + assert "Light day" in out + assert "1 session" in out or "one session" in out + assert "2 turns" in out + # Light days skip the heavy bulleted structure. + assert "**What you worked on:**" not in out + + def test_build_turn_recap_line_falls_back_to_request_and_handles_empty() -> None: assert build_turn_recap_line(request="fix the bug", assistant_text="") == ( "※ recap: fix the bug (disable recaps in /settings)" diff --git a/tests/tools/test_ask_user.py b/tests/tools/test_ask_user.py index 04364d8f..b08954be 100644 --- a/tests/tools/test_ask_user.py +++ b/tests/tools/test_ask_user.py @@ -343,3 +343,83 @@ async def test_ask_user_yolo_only_does_not_dismiss(): wire.shutdown() current_tool_call.reset(tc_token) _current_wire.reset(wire_token) + + +async def test_auto_deliberate_returns_verdict_not_empty_dismiss() -> None: + tool = AskUserQuestion() + tool.bind_auto(lambda: True, policy="auto_deliberate") + + async def fake_advisor(questions): + return "Ranking: 1) SQLite (simplest), 2) Postgres." + + tool.bind_deliberation(fake_advisor) + + params = Params( + questions=[ + QuestionParam( + question="Which DB?", + header="DB", + options=[ + QuestionOptionParam(label="SQLite"), + QuestionOptionParam(label="Postgres"), + ], + ) + ] + ) + result = await tool(params) + assert isinstance(result.output, str) + payload = json.loads(result.output) + assert payload["answers"] == {} # still no human answer + assert "SQLite" in payload["advisor"] # advisor verdict present + assert "decider" in payload["note"].lower() + + +async def test_auto_deliberate_falls_back_when_no_advisor() -> None: + tool = AskUserQuestion() + tool.bind_auto(lambda: True, policy="auto_deliberate") # no advisor bound + params = Params( + questions=[ + QuestionParam( + question="Which DB?", + header="DB", + options=[ + QuestionOptionParam(label="SQLite"), + QuestionOptionParam(label="Postgres"), + ], + ) + ] + ) + result = await tool(params) + assert isinstance(result.output, str) + payload = json.loads(result.output) + assert payload["answers"] == {} + assert "advisor" not in payload # no advisor -> safe self-decision prompt only + assert "decider" in payload["note"].lower() + + +async def test_auto_deliberate_interactive_does_not_short_circuit() -> None: + # policy auto_deliberate but a user IS present (not auto): Entry A must NOT + # fire — the tool should proceed to the normal ask path. + tool = AskUserQuestion() + tool.bind_auto(lambda: False, policy="auto_deliberate") + + async def fake_advisor(questions): + return "should not be called" + + tool.bind_deliberation(fake_advisor) + params = Params( + questions=[ + QuestionParam( + question="Which DB?", + header="DB", + options=[ + QuestionOptionParam(label="SQLite"), + QuestionOptionParam(label="Postgres"), + ], + ) + ] + ) + # No wire bound -> the normal path returns a Wire-unavailable error, proving + # Entry A did not short-circuit. + result = await tool(params) + assert result.is_error or "Wire" in (result.message or "") diff --git a/tests/ui_and_conv/test_empty_think_part_indicator.py b/tests/ui_and_conv/test_empty_think_part_indicator.py index 5d12023c..eb792b2a 100644 --- a/tests/ui_and_conv/test_empty_think_part_indicator.py +++ b/tests/ui_and_conv/test_empty_think_part_indicator.py @@ -382,8 +382,9 @@ def test_compaction_takes_priority_over_moon(): # Compaction starts — should show compaction, not moon view.dispatch_wire_message(CompactionBegin()) agent_blocks = view.compose_agent_output() - # Should be the compaction block, not the moon - assert len(agent_blocks) == 1 + # Should be the compaction block, not the moon. Compaction commits with a + # leading seam (blank row + block), so there are two blocks. + assert len(agent_blocks) == 2 assert view._compaction_block is not None diff --git a/tests/ui_and_conv/test_keymap_thinking.py b/tests/ui_and_conv/test_keymap_thinking.py new file mode 100644 index 00000000..1d94f7b5 --- /dev/null +++ b/tests/ui_and_conv/test_keymap_thinking.py @@ -0,0 +1,20 @@ +"""Shift+Tab is rebound from plan-toggle to thinking-cycle (plan moves to /plan).""" + +from __future__ import annotations + +from pythinker_code.ui.shell.keymap import ( + all_keybindings, + key_text, + keybinding_description, +) + + +def test_shift_tab_bound_to_thinking_cycle_not_plan() -> None: + assert key_text("app.thinking.cycle") == "shift+tab" + # plan mode moved to the /plan slash command; no keybinding remains + assert "app.plan.toggle" not in all_keybindings() + assert key_text("app.plan.toggle") == "" + + +def test_thinking_cycle_has_help_description() -> None: + assert keybinding_description("app.thinking.cycle") == "change thinking effort" diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index 8a69d744..3aeda6e0 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -2,7 +2,7 @@ from pythinker_core.message import ToolCall from pythinker_core.tooling import ToolResult, ToolReturnValue -from rich.console import Console +from rich.console import Console, Group from pythinker_code.tools.display import TodoDisplayBlock, TodoDisplayItem from pythinker_code.ui.shell.console import console as shell_console @@ -10,6 +10,7 @@ from pythinker_code.ui.shell.visualize import _live_view as live_view_module from pythinker_code.ui.shell.visualize import _LiveView, _PromptLiveView from pythinker_code.wire.types import ( + CompactionBegin, HookOutput, HookResolved, HookTriggered, @@ -318,20 +319,32 @@ def test_prompt_live_view_keeps_non_background_task_notifications(monkeypatch): def test_live_view_prints_turn_recap_when_enabled(monkeypatch): printed = [] - monkeypatch.setattr( - live_view_module.console, "print", lambda *args, **_kwargs: printed.extend(args) - ) + + def fake_print(*args, **_kwargs): + printed.append(args[0] if args else None) + + monkeypatch.setattr(live_view_module.console, "print", fake_print) view = _LiveView(StatusUpdate(), show_turn_recaps=True) view.dispatch_wire_message(TurnBegin(user_input="implement recaps")) - view.dispatch_wire_message(TextPart(text="Implemented a /recap command.")) + view.dispatch_wire_message(TextPart(text="Implemented a ")) + view.dispatch_wire_message(TextPart(text="/recap command.")) view.dispatch_wire_message(TurnEnd()) view.cleanup(is_interrupt=False) - plain = "\n".join(getattr(item, "plain", str(item)) for item in printed) + plain = "\n".join(getattr(item, "plain", str(item)) for item in printed if item is not None) assert "※ recap: Implemented a /recap command." in plain + assert "Implemented a /recap" not in plain assert "disable recaps in /settings" in plain + recap_index = next( + index + for index, item in enumerate(printed) + if item is not None and "※ recap:" in getattr(item, "plain", str(item)) + ) + assert printed[recap_index - 1] is None + assert printed[recap_index + 1] is None + def test_cleanup_flushes_notifications_to_terminal_history(monkeypatch): view = _LiveView(StatusUpdate()) @@ -371,6 +384,15 @@ def test_cleanup_flushes_all_notifications_even_when_live_view_shows_only_latest assert f"Background task completed: build project {index}" in rendered +def test_compaction_status_keeps_one_blank_row_above_live_block(): + view = _LiveView(StatusUpdate(context_tokens=221_300)) + + view.dispatch_wire_message(CompactionBegin()) + + rendered = _render(Group(*view.compose_agent_output(include_working_indicator=False))) + assert rendered.startswith("\n· Compacting conversation…") + + def test_compose_inserts_gap_under_agent_output_before_nonempty_status(): """Non-interactive compose() puts one blank row under the spinner verb before a non-empty status line (the under-gap), and the status line stays last.""" diff --git a/tests/ui_and_conv/test_live_view_todos.py b/tests/ui_and_conv/test_live_view_todos.py index 55578a2a..a7f59f7e 100644 --- a/tests/ui_and_conv/test_live_view_todos.py +++ b/tests/ui_and_conv/test_live_view_todos.py @@ -8,13 +8,20 @@ from rich.console import Console, Group from rich.style import Style -from pythinker_code.tools.display import TodoDisplayBlock, TodoDisplayItem +from pythinker_code.tools.display import DiffDisplayBlock, TodoDisplayBlock, TodoDisplayItem +from pythinker_code.ui.shell.motion import ( + _SHIMMER_BASE, + _SHIMMER_HIGHLIGHT, + _SHIMMER_MID, +) from pythinker_code.ui.shell.visualize import _LiveView from pythinker_code.ui.theme import tui_rich_style from pythinker_code.wire.types import StatusUpdate, TurnBegin _live_view_module = importlib.import_module("pythinker_code.ui.shell.visualize._live_view") +_SHIMMER_HEXES = {_SHIMMER_BASE.lower(), _SHIMMER_MID.lower(), _SHIMMER_HIGHLIGHT.lower()} + def _render(renderable) -> str: console = Console(width=100, record=True, highlight=False) @@ -165,17 +172,17 @@ def test_finished_todos_move_to_bottom_of_menu(monkeypatch) -> None: assert rendered.index("✓ Finished first") < rendered.index("✓ Finished second") -def test_active_todo_activity_line_uses_warning_accent() -> None: +def test_active_todo_activity_line_uses_standard_spinner_shimmer() -> None: view = _LiveView(StatusUpdate(context_tokens=10_000)) line = view._todo_activity_line("Implement pinned todos", elapsed_s=0.88, width=100) - assert _span_colors_for(line, "Implement pinned todos") == { - _color_hex(tui_rich_style("warning").color) - } + marker_style = Style.parse(line.style) if isinstance(line.style, str) else line.style + assert marker_style.color == tui_rich_style("thinking_text").color + assert _span_colors_for(line, "Implement pinned todos") >= _SHIMMER_HEXES -def test_active_pinned_todo_row_uses_accent_icon_and_white_title() -> None: +def test_active_pinned_todo_row_uses_neutral_title_not_shimmer() -> None: view = _LiveView(StatusUpdate()) row = view._pinned_todo_row( @@ -184,11 +191,20 @@ def test_active_pinned_todo_row_uses_accent_icon_and_white_title() -> None: width=100, elapsed_s=0.88, ) - title_style = _style_for(row, "Implement pinned todos") + active_color = _color_hex(tui_rich_style("activity_label").color) + shimmer_colors = _SHIMMER_HEXES assert _span_colors_for(row, "■") == {_color_hex(tui_rich_style("warning").color)} - assert title_style.color == tui_rich_style("activity_label").color - assert title_style.bold is True + assert _span_colors_for(row, "Implement pinned todos") == {active_color} + assert _span_colors_for(row, "Implement pinned todos").isdisjoint(shimmer_colors) + title_start = row.plain.index("Implement pinned todos") + title_end = title_start + len("Implement pinned todos") + assert any( + span.start <= title_start + and span.end >= title_end + and (Style.parse(span.style) if isinstance(span.style, str) else span.style).bold + for span in row.spans + ) def test_pinned_todo_rows_align_icons_and_titles() -> None: @@ -236,6 +252,7 @@ def test_completed_todo_row_is_muted_and_struck() -> None: ) title_style = _style_for(row, "Finished task") + assert _span_colors_for(row, "✓") == {_color_hex(tui_rich_style("muted").color)} assert title_style.strike is True assert title_style.color == tui_rich_style("muted").color @@ -251,3 +268,28 @@ def test_toggle_pinned_todos_hides_todo_rows() -> None: rendered = _render(view._working_indicator()) assert "Implement pinned todos" not in rendered assert "…" in rendered + + +def test_turn_recap_tracks_and_clears_modified_files() -> None: + view = _LiveView(StatusUpdate()) + result = ToolResult( + tool_call_id="1", + return_value=ToolReturnValue( + is_error=False, + output="ok", + message="ok", + display=[ + DiffDisplayBlock( + path="src/a.py", old_text="", new_text="x", old_start=1, new_start=1 + ) + ], + ), + ) + + view._track_recap_modified_files(result) + view._track_recap_modified_files(result) # idempotent — backed by a set + assert view._recap_files_modified == {"src/a.py"} + + # A fresh top-level turn resets the per-turn delta state. + view.dispatch_wire_message(TurnBegin(user_input="next ask")) + assert view._recap_files_modified == set() diff --git a/tests/ui_and_conv/test_openai_shell_login.py b/tests/ui_and_conv/test_openai_shell_login.py index fef98a2e..a86071c3 100644 --- a/tests/ui_and_conv/test_openai_shell_login.py +++ b/tests/ui_and_conv/test_openai_shell_login.py @@ -102,13 +102,62 @@ async def test_shell_setup_routes_to_openai_api_key(monkeypatch): assert api_key.call_args.args[1] == "sk-test" +def _configure_minimax(config: Config) -> None: + from pydantic import SecretStr + + from pythinker_code.auth.minimax import MINIMAX_ANTHROPIC_PROVIDER_KEY + from pythinker_code.config import LLMProvider + + config.providers[MINIMAX_ANTHROPIC_PROVIDER_KEY] = LLMProvider( + type="anthropic", + base_url="https://api.minimax.io/anthropic", + api_key=SecretStr("mx-saved"), + ) + + +def test_provider_status_reflects_saved_provider_key(): + config = Config(is_from_default_location=True) + assert shell_oauth._get_provider_status(config, "minimax").source == "unconfigured" + _configure_minimax(config) + assert shell_oauth._get_provider_status(config, "minimax").source == "configured" + assert shell_oauth._get_provider_status(config, "deepseek").source == "unconfigured" + + +@pytest.mark.asyncio +async def test_shell_logout_no_args_opens_selector(monkeypatch): + app = _app() + _configure_minimax(app.soul.runtime.config) + logout = Mock(side_effect=_success_event) + monkeypatch.setattr(shell_oauth, "logout_minimax", logout, raising=False) + monkeypatch.setattr(shell_oauth, "run_oauth_selector", lambda *a, **kw: _async_value("minimax")) + + with pytest.raises(Reload): + await cast(Any, shell_oauth.logout)(app, "") + + assert logout.called + + +@pytest.mark.asyncio +async def test_shell_logout_no_args_without_providers_returns_silently(monkeypatch): + logout = Mock(side_effect=_success_event) + monkeypatch.setattr(shell_oauth, "logout_openai", logout, raising=False) + selector = Mock() + monkeypatch.setattr(shell_oauth, "run_oauth_selector", selector) + + # Nothing is logged in: must not open the selector and must not log anyone out. + await cast(Any, shell_oauth.logout)(_app(), "") + + assert not selector.called + assert not logout.called + + @pytest.mark.asyncio -async def test_shell_logout_routes_to_openai_logout(monkeypatch): +async def test_shell_logout_openai_routes_to_openai_logout(monkeypatch): logout = Mock(side_effect=_success_event) monkeypatch.setattr(shell_oauth, "logout_openai", logout, raising=False) with pytest.raises(Reload): - await cast(Any, shell_oauth.logout)(_app(), "") + await cast(Any, shell_oauth.logout)(_app(), "openai") assert logout.called diff --git a/tests/ui_and_conv/test_plan_display_panel.py b/tests/ui_and_conv/test_plan_display_panel.py index 56fb8d3a..ec872efc 100644 --- a/tests/ui_and_conv/test_plan_display_panel.py +++ b/tests/ui_and_conv/test_plan_display_panel.py @@ -24,7 +24,7 @@ def fake_render_worklog_card(title, body, *, subtitle=None, border_style="grey50 return panel monkeypatch.setattr(_live_view, "render_worklog_card", fake_render_worklog_card) - monkeypatch.setattr(_live_view.console, "print", printed.append) + monkeypatch.setattr(_live_view.console, "print", lambda *args, **kwargs: printed.extend(args)) view = _live_view._LiveView(StatusUpdate()) view.display_plan(PlanDisplay(content="# Steps\n\n- Step one", file_path="plans/one.md")) @@ -32,6 +32,9 @@ def fake_render_worklog_card(title, body, *, subtitle=None, border_style="grey50 assert card_call["title"] == "Plan" assert card_call["subtitle"] == "plans/one.md" assert card_call["border_style"] == tui_rich_style("border") + # _print_action_block emits a leading blank line (zero-arg console.print()) + # before the panel; only positional args are captured, so printed holds the + # panel alone. assert printed == [card_call["panel"]] console = Console(record=True, width=120, color_system=None) diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 947662b7..e9f3eb2a 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -11,6 +11,7 @@ import pytest from prompt_toolkit.completion import Completion +from pythinker_code.llm import ModelCapability from pythinker_code.soul import StatusSnapshot from pythinker_code.ui.shell import prompt as shell_prompt from pythinker_code.ui.shell.prompt import ( @@ -74,6 +75,25 @@ def test_prompt_continuation_aligns_wrapped_input_with_text_start() -> None: assert "".join(fragment[1] for fragment in fragments) == " " +def test_agent_input_text_keeps_default_color_when_thinking_changes() -> None: + prompt_session = object.__new__(CustomPromptSession) + prompt_session._mode = PromptMode.AGENT + prompt_session._thinking_effort = "high" + prompt_session._thinking = True + + assert prompt_session._thinking_input_style() == "class:compact-input" + assert prompt_session._thinking_prompt_prefix_style() == "class:compact-input.prompt" + + +def test_shell_input_keeps_default_compact_input_style() -> None: + prompt_session = object.__new__(CustomPromptSession) + prompt_session._mode = PromptMode.SHELL + prompt_session._thinking_effort = "high" + prompt_session._thinking = True + + assert prompt_session._thinking_input_style() == "class:compact-input" + + def test_prompt_right_padding_margin_reserves_blank_edge_columns() -> None: margin = _PromptRightPaddingMargin(lambda: 2) render_info = SimpleNamespace(displayed_lines=[0, 0, 0]) @@ -168,11 +188,17 @@ def handle_running_prompt_key(self, key: str, event) -> None: raise AssertionError("Should not be called in this test") -def _make_toolbar_session(*, model_name: str | None = None, tips: list[str] | None = None) -> Any: +def _make_toolbar_session( + *, + model_name: str | None = None, + model_capabilities: set[ModelCapability] | None = None, + tips: list[str] | None = None, +) -> Any: """Build a minimal CustomPromptSession for toolbar rendering tests.""" prompt_session = object.__new__(CustomPromptSession) prompt_session._mode = PromptMode.AGENT prompt_session._model_name = model_name + prompt_session._model_capabilities = model_capabilities or set() prompt_session._thinking = False prompt_session._status_provider = lambda: StatusSnapshot(context_usage=0.0) prompt_session._background_task_count_provider = None @@ -245,7 +271,7 @@ def test_build_toolbar_tips_without_clipboard() -> None: assert _build_toolbar_tips(clipboard_available=False) == [ "?: shortcuts", "ctrl+x: toggle mode", - "shift+tab: plan mode", + "shift+tab: change thinking effort", "!: shell command", "ctrl+o: editor", "ctrl+t: toggle todos", @@ -260,7 +286,7 @@ def test_build_toolbar_tips_with_clipboard() -> None: assert _build_toolbar_tips(clipboard_available=True) == [ "?: shortcuts", "ctrl+x: toggle mode", - "shift+tab: plan mode", + "shift+tab: change thinking effort", "!: shell command", "ctrl+o: editor", "ctrl+t: toggle todos", @@ -272,6 +298,12 @@ def test_build_toolbar_tips_with_clipboard() -> None: ] +def test_working_spinner_tip_mentions_thinking_effort_shortcut() -> None: + from pythinker_code.ui.shell.tips import current_tip + + assert current_tip(0) == "Shift+Tab changes thinking effort levels" + + # ── _display_width ───────────────────────────────────────────────────────────── @@ -567,7 +599,39 @@ def get_size() -> Any: fragments = list(prompt_session._render_bottom_toolbar()) assert (f"fg:{tokens.muted}", "context: 0.0%") in fragments - assert (f"fg:{tokens.text or tokens.activity_label}", "agent fast-model ○") in fragments + assert ( + f"fg:{tokens.text or tokens.activity_label}", + "agent fast-model • thinking off", + ) in fragments + + +def test_card_toolbar_separator_matches_thinking_prompt_color(monkeypatch: Any) -> None: + from pythinker_code.ui.theme import set_active_theme, thinking_frame_style + + prompt_session = _make_toolbar_session(model_name="fast-model", tips=[]) + prompt_session._thinking = True + prompt_session._thinking_effort = "xhigh" + + class _DummyOutput: + @staticmethod + def get_size() -> Any: + return SimpleNamespace(columns=120) + + set_active_theme("dark") + monkeypatch.setenv("PYTHINKER_TUI_STYLE", "card") + monkeypatch.setattr( + shell_prompt, "get_app_or_none", lambda: SimpleNamespace(output=_DummyOutput()) + ) + monkeypatch.setattr(shell_prompt, "_get_git_branch", lambda: None) + monkeypatch.setattr(shell_prompt, "_shorten_cwd", lambda _: "~/proj") + monkeypatch.setattr("pythinker_code.extensions.footer_statuses", lambda: {}) + + fragments = list(prompt_session._render_bottom_toolbar()) + + assert fragments[0] == ( + thinking_frame_style("xhigh", theme="dark"), + shell_prompt._prompt_rule(120), + ) def test_bottom_toolbar_drops_agent_badge_before_bash_when_narrow(monkeypatch: Any) -> None: @@ -588,12 +652,27 @@ def test_bottom_toolbar_drops_agent_badge_before_bash_when_narrow(monkeypatch: A def test_mode_shows_full_with_model_name_on_wide_terminal(monkeypatch: Any) -> None: - """On a wide terminal the full mode string (with model name and thinking dot) is shown.""" + """On a wide terminal the full mode string includes model and thinking effort.""" session = _make_toolbar_session(model_name="fast-model") session._thinking = False lines = _render_toolbar_lines(session, 80, monkeypatch) assert "fast-model" in lines[1], f"model name missing on wide terminal: {lines[1]!r}" - assert "○" in lines[1], f"thinking dot missing on wide terminal: {lines[1]!r}" + assert "thinking off" in lines[1], f"thinking effort missing on wide terminal: {lines[1]!r}" + + +def test_native_reasoning_model_does_not_show_thinking_off(monkeypatch: Any) -> None: + session = _make_toolbar_session( + model_name="MiniMax M2.7", + model_capabilities={"always_thinking"}, + ) + session._thinking = False + session._thinking_effort = "off" + + lines = _render_toolbar_lines(session, 100, monkeypatch) + + assert "MiniMax M2.7" in lines[1] + assert "native reasoning" in lines[1] + assert "thinking off" not in lines[1] def test_toolbar_mode_is_light_and_secondary_text_is_muted(monkeypatch: Any) -> None: @@ -604,15 +683,18 @@ def test_toolbar_mode_is_light_and_secondary_text_is_muted(monkeypatch: Any) -> session = _make_toolbar_session(model_name="fast-model", tips=["?: shortcuts"]) fragments = _render_toolbar_fragments(session, 120, monkeypatch) - assert (f"fg:{tokens.text or tokens.activity_label}", "agent (fast-model ○)") in fragments + assert ( + f"fg:{tokens.text or tokens.activity_label}", + "agent (fast-model • thinking off)", + ) in fragments assert (f"fg:{tokens.muted} bold", "?") in fragments assert (f"fg:{tokens.muted}", ": shortcuts") in fragments def test_mode_drops_model_name_on_narrow_terminal(monkeypatch: Any) -> None: """On a terminal too narrow for the full mode string, model name is dropped but - the thinking dot is still shown.""" - # "agent (a-very-long-model-name-that-is-40-chars ○)" is ~50 cols; + the thinking effort is still shown.""" + # "agent (a-very-long-model-name-that-is-40-chars • high)" is ~55 cols; # a 30-col terminal forces mid-level degradation. long_model = "a-very-long-model-name-that-is-40-chars" session = _make_toolbar_session(model_name=long_model) @@ -621,7 +703,7 @@ def test_mode_drops_model_name_on_narrow_terminal(monkeypatch: Any) -> None: assert long_model not in lines[1], ( f"model name should be dropped on 30-col terminal: {lines[1]!r}" ) - assert "●" in lines[1], f"thinking dot should still appear at mid level: {lines[1]!r}" + assert "high" in lines[1], f"thinking effort should still appear at mid level: {lines[1]!r}" assert _display_width(lines[1]) <= 30 @@ -1036,7 +1118,9 @@ def test_prompt_buffer_window_can_grow_to_five_visible_rows() -> None: height = cast(shell_prompt.Dimension, buffer_window.height) assert height.min == 1 assert height.max == 5 - assert buffer_window.style == "class:compact-input" + style = buffer_window.style + assert callable(style) + assert cast(Callable[[], str], style)() == prompt_session._thinking_input_style() @pytest.mark.asyncio @@ -1251,6 +1335,12 @@ def test_modal_prompt_suspends_and_restores_existing_draft_when_input_is_hidden( assert prompt_session._suspended_buffer_document is None +def test_prompt_rule_keeps_rightmost_column_clear() -> None: + assert shell_prompt._prompt_rule(0) == "" + assert shell_prompt._prompt_rule(1) == "" + assert shell_prompt._prompt_rule(8) == "─" * 7 + + def test_idle_agent_prompt_uses_same_codex_input_layout(monkeypatch: Any) -> None: width = 64 prompt_session = object.__new__(CustomPromptSession) @@ -1269,7 +1359,9 @@ def get_size() -> Any: rendered_message = prompt_session._render_agent_prompt_message() plain_message = "".join(fragment[1] for fragment in rendered_message) - assert plain_message == f"{'─' * width}\n ❯ " + # Keep the rightmost terminal column clear. Full-width prompt-toolkit chrome + # is prone to resize artifacts in non-fullscreen prompts. + assert plain_message == f"{'─' * (width - 1)}\n ❯ " assert "input" not in plain_message diff --git a/tests/ui_and_conv/test_report.py b/tests/ui_and_conv/test_report.py index ee3e3846..adee0c6d 100644 --- a/tests/ui_and_conv/test_report.py +++ b/tests/ui_and_conv/test_report.py @@ -70,6 +70,36 @@ def test_render_report_summary_tally_and_no_critical_high(): assert "no critical or high" in out +def test_render_report_uses_padded_reading_surface(): + out = _plain(render_report(_sample_report()), width=100) + + assert "╭" in out + assert "Code Review Results" in out + assert "│ Reviewed 17 files across 3 clusters" in out + + +def test_render_report_hanging_indents_wrapped_locations(): + report = Report( + title="Deep Code Scan Results", + findings=( + ReportFinding( + "Pythinker provider loses minimal round-trip", + "medium", + location=( + "packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py:138-148; " + "packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py:198-209" + ), + ), + ), + ) + + out = _plain(render_report(report), width=120) + location_lines = [line for line in out.splitlines() if "pythinker.py" in line] + + assert len(location_lines) >= 2 + assert location_lines[0].index("packages") == location_lines[1].index("packages") + + def test_render_report_groups_in_severity_order_regardless_of_input(): report = Report( title="t", diff --git a/tests/ui_and_conv/test_report_fence_nesting.py b/tests/ui_and_conv/test_report_fence_nesting.py index 05575910..b4b74ee7 100644 --- a/tests/ui_and_conv/test_report_fence_nesting.py +++ b/tests/ui_and_conv/test_report_fence_nesting.py @@ -65,5 +65,7 @@ def test_report_fence_with_exotic_line_separator_does_not_leak_delimiter(): assert "Real" in out assert "1 medium" in out # No leaked closing fence -> no spurious bordered code block under the report. + # The report itself renders as a single rounded Panel (one ╭/╰ pair); a leaked + # fence would render as a second bordered box, so the count must stay at one. assert "```" not in out - assert "╭" not in out and "╰" not in out + assert out.count("╭") == 1 and out.count("╰") == 1 diff --git a/tests/ui_and_conv/test_settings_selector.py b/tests/ui_and_conv/test_settings_selector.py index cd519749..69b6a927 100644 --- a/tests/ui_and_conv/test_settings_selector.py +++ b/tests/ui_and_conv/test_settings_selector.py @@ -99,6 +99,35 @@ def test_build_settings_config_model_values_include_config_models(): assert "beta" in item.values +def test_build_settings_config_labels_native_reasoning_as_read_only(): + config = Config( + providers={ + "p": LLMProvider( + type="anthropic", + base_url="https://example.test", + api_key=SecretStr("k"), + ) + }, + models={ + "minimax/m2.7": LLMModel( + provider="p", + model="MiniMax-M2.7", + max_context_size=192_000, + capabilities={"always_thinking"}, + ), + }, + default_model="minimax/m2.7", + default_thinking=False, + default_thinking_effort="off", + ) + + settings = _build_settings_config(config) + item = next(item for item in settings.items if item.id == "default_thinking") + + assert item.current_value == "native reasoning" + assert item.values is None + + def test_apply_settings_changes_mutates_config(): config = Config() diff --git a/tests/ui_and_conv/test_shell_design_system.py b/tests/ui_and_conv/test_shell_design_system.py index 7b8ef35a..230904f7 100644 --- a/tests/ui_and_conv/test_shell_design_system.py +++ b/tests/ui_and_conv/test_shell_design_system.py @@ -88,8 +88,8 @@ def test_shell_style_resolves_brand_tokens_and_switches_theme(): def test_verb_spinner_stays_muted_yellow_independent_of_accent_token(): - from pythinker_code.ui.shell.motion import verb_spinner_style + from pythinker_code.ui.shell.motion import _SHIMMER_BASE, verb_spinner_style from pythinker_code.ui.theme import set_active_theme set_active_theme("dark") - assert _color_hex(verb_spinner_style()) == "#e6b450" + assert _color_hex(verb_spinner_style()) == _SHIMMER_BASE.lower() diff --git a/tests/ui_and_conv/test_shell_motion.py b/tests/ui_and_conv/test_shell_motion.py index 18cddf4a..41b6b844 100644 --- a/tests/ui_and_conv/test_shell_motion.py +++ b/tests/ui_and_conv/test_shell_motion.py @@ -13,12 +13,17 @@ SPINNER_FRAMES, ) from pythinker_code.ui.shell.motion import ( + _SHIMMER_BASE, + _SHIMMER_HIGHLIGHT, + _SHIMMER_MID, ActivitySnapshot, active_marker_frame, activity_status_line, spinner_frame_at, ) +_SHIMMER_HEXES = {_SHIMMER_BASE.lower(), _SHIMMER_MID.lower(), _SHIMMER_HIGHLIGHT.lower()} + def _plain(renderable) -> str: console = Console(record=True, width=100, color_system=None) @@ -135,8 +140,8 @@ def test_activity_status_line_uses_silver_spinner_and_muted_yellow_verb(): base_style = Style.parse(start.style) if isinstance(start.style, str) else start.style assert _color_hex(base_style.color) == "#c0c0c0" - assert _span_colors_for(sheen, "Cultivating") >= {"#e6b450", "#ebc46e", "#f3d89a"} - assert _span_colors_for(later_sheen, "Cultivating") >= {"#e6b450", "#ebc46e", "#f3d89a"} + assert _span_colors_for(sheen, "Cultivating") >= _SHIMMER_HEXES + assert _span_colors_for(later_sheen, "Cultivating") >= _SHIMMER_HEXES assert "Cultivating…" in _plain(start) diff --git a/tests/ui_and_conv/test_shell_motion_shimmer.py b/tests/ui_and_conv/test_shell_motion_shimmer.py index dd828850..1185ac4f 100644 --- a/tests/ui_and_conv/test_shell_motion_shimmer.py +++ b/tests/ui_and_conv/test_shell_motion_shimmer.py @@ -1,9 +1,26 @@ from rich.color import Color -from pythinker_code.ui.shell.motion import shimmer_prompt_fragments, shimmer_spinner_style +from pythinker_code.ui.shell.motion import ( + _SHIMMER_BASE, + _SHIMMER_HIGHLIGHT, + _SHIMMER_INTERVAL_S, + _SHIMMER_MID, + _shimmer_segments, + shimmer_prompt_fragments, + shimmer_spinner_style, +) from pythinker_code.ui.theme import set_active_theme +def _frame_colors(label: str, frame: int) -> list[str | None]: + """Per-character colors at a given integer animation frame.""" + elapsed = (frame + 0.5) * _SHIMMER_INTERVAL_S + colors: list[str | None] = [] + for color, text in _shimmer_segments(label, elapsed, reduced_motion=False): + colors.extend([color] * len(text)) + return colors + + def _color_hex(color: Color | None) -> str: assert color is not None triplet = color.triplet @@ -14,7 +31,7 @@ def _color_hex(color: Color | None) -> str: def test_shimmer_returns_base_accent_when_reduced_motion(): set_active_theme("dark") s = shimmer_spinner_style(0.0, reduced_motion=True) - assert _color_hex(s.color) == "#e6b450" + assert _color_hex(s.color) == _SHIMMER_BASE.lower() def test_shimmer_varies_over_time_when_motion_enabled(monkeypatch): @@ -23,16 +40,64 @@ def test_shimmer_varies_over_time_when_motion_enabled(monkeypatch): first = _color_hex(shimmer_spinner_style(0.0, reduced_motion=False).color) later = _color_hex(shimmer_spinner_style(0.22, reduced_motion=False).color) # At least one sampled frame differs from the base when animating. - assert first != later or first != "#e6b450" + assert first != later or first != _SHIMMER_BASE.lower() -def test_prompt_shimmer_fragments_share_muted_yellow_palette(monkeypatch): +def test_prompt_shimmer_fragments_share_silver_sheen_palette(monkeypatch): monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) fragments = shimmer_prompt_fragments("Schlepping…", 0.88) styles = {style.lower() for style, text in fragments if text.strip()} - assert "fg:#e6b450" in styles - assert "fg:#ebc46e" in styles - assert "fg:#f3d89a" in styles + assert f"fg:{_SHIMMER_BASE.lower()}" in styles + assert f"fg:{_SHIMMER_MID.lower()}" in styles + assert f"fg:{_SHIMMER_HIGHLIGHT.lower()}" in styles assert "".join(text for _style, text in fragments) == "Schlepping…" + + +def test_splash_originates_at_center_and_widens(monkeypatch): + monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) + label = "abcdefg" # n=7, center index 3, no spaces + wave_len = len(label) + 6 # phase B (first splash) starts here + + first = _frame_colors(label, wave_len) + highlighted_first = [i for i, c in enumerate(first) if c == _SHIMMER_HIGHLIGHT] + assert highlighted_first == [3] # bloom begins at the center char + + second = _frame_colors(label, wave_len + 1) + highlighted_second = [i for i, c in enumerate(second) if c == _SHIMMER_HIGHLIGHT] + assert highlighted_second == [2, 4] # wavefront expands symmetrically outward + assert second[3] == _SHIMMER_MID # interior fills behind the front + + +def test_phase_c_trail_mirrors_phase_a(monkeypatch): + monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) + label = "abcdefg" + n = len(label) + wave_len = n + 6 + splash_len = (n + 1) // 2 + 3 + + # Phase A (right-to-left) and phase C (left-to-right) with the head at index 3. + phase_a = _frame_colors(label, n + 2 - 3) + phase_c = _frame_colors(label, wave_len + splash_len + (3 + 2)) + + assert phase_a[3] == _SHIMMER_HIGHLIGHT + assert phase_c[3] == _SHIMMER_HIGHLIGHT + + a_mid = [i for i, c in enumerate(phase_a) if c == _SHIMMER_MID] + c_mid = [i for i, c in enumerate(phase_c) if c == _SHIMMER_MID] + # Phase A trail leans right of the head; phase C trail is mirrored to the left. + assert max(a_mid) > 3 + assert min(c_mid) < 3 + assert a_mid != c_mid + + +def test_cycle_returns_to_start(monkeypatch): + monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) + label = "Reticulating" + n = len(label) + cycle_len = 2 * (n + 6) + 2 * ((n + 1) // 2 + 3) + + assert _frame_colors(label, 3) == _frame_colors(label, 3 + cycle_len) + # A frame mid-cycle differs from the start (the animation actually moves). + assert _frame_colors(label, 3) != _frame_colors(label, 3 + cycle_len // 2) diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index b49fa8f9..fe20d3c8 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -335,6 +335,19 @@ def test_composing_commits_on_newline(self): pending = block.raw_text[block._committed_len :] assert "Third." in pending + def test_report_fence_continuation_keeps_gap_after_streamed_prose(self): + output_console = Console(record=True, width=100, color_system=None) + + block = _ContentBlock(is_think=False) + block.append("Deep scan completed. Full report saved here:\n") + block.append(" .pythinker/reports/deep-code-scan.md\n") + block.append('```report\n{"title": "Deep Code Scan Results", "findings": []}\n```\n\n') + output_console.print(block.compose_final()) + output = output_console.export_text() + + assert output.startswith("\n") + assert "Deep Code Scan Results" in output + def test_composing_no_commit_without_newline(self): block = _ContentBlock(is_think=False) block.append("just some text without newlines") diff --git a/tests/ui_and_conv/test_thinking_cycle.py b/tests/ui_and_conv/test_thinking_cycle.py new file mode 100644 index 00000000..71da1350 --- /dev/null +++ b/tests/ui_and_conv/test_thinking_cycle.py @@ -0,0 +1,92 @@ +"""Tests for the Shift+Tab thinking-level cycle helper.""" + +from __future__ import annotations + +import pytest + +from pythinker_code.ui.shell.selectors.thinking import ( + THINKING_LEVELS, + ThinkingLevel, + next_thinking_level, +) + + +def test_thinking_levels_canonical_order() -> None: + assert THINKING_LEVELS == ("off", "minimal", "low", "medium", "high", "xhigh") + + +@pytest.mark.parametrize( + ("current", "expected"), + [ + ("off", "minimal"), + ("minimal", "low"), + ("low", "medium"), + ("medium", "high"), + ("high", "xhigh"), + ("xhigh", "off"), # wrap-around + ], +) +def test_next_thinking_level_cycles_and_wraps( + current: ThinkingLevel, expected: ThinkingLevel +) -> None: + assert next_thinking_level(current) == expected + + +def test_thinking_frame_color_maps_each_level_dark() -> None: + from pythinker_code.ui.theme import thinking_frame_color + + assert thinking_frame_color("off", theme="dark") == "#94A3B8" # slate + assert thinking_frame_color("minimal", theme="dark") == "#60A5FA" # blue + assert thinking_frame_color("low", theme="dark") == "#22D3EE" # cyan + assert thinking_frame_color("medium", theme="dark") == "#34D399" # emerald + assert thinking_frame_color("high", theme="dark") == "#FBBF24" # amber + assert thinking_frame_color("xhigh", theme="dark") == "#FB7185" # rose + + +def test_thinking_frame_color_light_differs_from_dark() -> None: + from pythinker_code.ui.theme import thinking_frame_color + + assert thinking_frame_color("high", theme="light") == "#92400E" + assert thinking_frame_color("high", theme="light") != thinking_frame_color("high", theme="dark") + + +def test_thinking_frame_color_unknown_level_falls_back_to_border() -> None: + from pythinker_code.ui.theme import get_tui_tokens, thinking_frame_color + + assert thinking_frame_color("bogus", theme="dark") == get_tui_tokens("dark").border + + +def test_thinking_frame_style_is_ptk_fg_directive() -> None: + from pythinker_code.ui.theme import thinking_frame_style + + assert thinking_frame_style("high", theme="dark") == "fg:#FBBF24" + + +def test_core_thinking_cycle_uses_available_model_levels() -> None: + from pythinker_code.thinking import next_thinking_level + + assert next_thinking_level("off", ("off", "high", "xhigh")) == "high" + assert next_thinking_level("high", ("off", "high", "xhigh")) == "xhigh" + assert next_thinking_level("xhigh", ("off", "high", "xhigh")) == "off" + + +def test_core_thinking_clamps_unsupported_level_up_then_down() -> None: + from pythinker_code.thinking import clamp_thinking_effort + + assert clamp_thinking_effort("low", ("off", "high")) == "high" + assert clamp_thinking_effort("xhigh", ("off", "high")) == "high" + assert clamp_thinking_effort("off", ("minimal", "low", "high")) == "minimal" + + +def test_effective_config_thinking_effort_treats_effort_as_source_of_truth() -> None: + from pythinker_code.thinking import effective_config_thinking_effort + + # The explicit effort field wins whenever it is set (it is the SSOT; every + # writer keeps the legacy bool in sync). The bool is only a fallback when the + # effort is unset (configs written before the effort field existed). + assert effective_config_thinking_effort(False, "high") == "high" + assert effective_config_thinking_effort(True, None) == "high" + # Explicit "off" must beat the legacy bool (was the Finding 3 regression). + assert effective_config_thinking_effort(True, "off") == "off" + assert effective_config_thinking_effort(True, "xhigh") == "xhigh" + assert effective_config_thinking_effort(False, None) == "off" diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index a4a6f426..1dfdcb8d 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -861,7 +861,7 @@ def test_ask_user_renders_question_and_options(): }, ) assert "● Ask(1 question)" in rendered - assert "Which auth method?" in rendered + assert "● Ask(1 question)\n\n? Which auth method?" in rendered assert "OAuth" in rendered assert "API key" in rendered diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 3a178b3e..a1acb909 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -9,6 +9,7 @@ from rich.text import Text from pythinker_code.tools.display import TodoDisplayItem +from pythinker_code.ui.shell.motion import _SHIMMER_BASE, _SHIMMER_HIGHLIGHT, _SHIMMER_MID from pythinker_code.ui.shell.prompt import BgTaskCounts, CustomPromptSession, PromptMode, UserInput from pythinker_code.wire.types import ( ApprovalRequest, @@ -141,6 +142,63 @@ def test_render_pinned_status_tail_empty_when_turn_inactive() -> None: assert view2.render_pinned_status_tail(80).value == "" +@pytest.mark.asyncio +async def test_prompt_live_view_status_refresh_invalidates_active_turn(monkeypatch) -> None: + invalidations: list[str] = [] + + class _PromptSession: + def invalidate(self) -> None: + invalidations.append("invalidate") + + monkeypatch.setattr(_interactive_mod, "_STATUS_REFRESH_INTERVAL_S", 0.001) + view = _PromptLiveView( + StatusUpdate(), + prompt_session=cast(Any, _PromptSession()), + steer=lambda _content: None, + ) + view._active_turn_depth = 1 + view._turn_ended = False + + task = asyncio.create_task(view._status_refresh_loop()) + try: + for _ in range(20): + if invalidations: + break + await asyncio.sleep(0.002) + assert invalidations + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_prompt_live_view_status_refresh_skips_inactive_turn(monkeypatch) -> None: + invalidations: list[str] = [] + + class _PromptSession: + def invalidate(self) -> None: + invalidations.append("invalidate") + + monkeypatch.setattr(_interactive_mod, "_STATUS_REFRESH_INTERVAL_S", 0.001) + view = _PromptLiveView( + StatusUpdate(), + prompt_session=cast(Any, _PromptSession()), + steer=lambda _content: None, + ) + view._active_turn_depth = 0 + view._turn_ended = False + + task = asyncio.create_task(view._status_refresh_loop()) + try: + await asyncio.sleep(0.006) + assert invalidations == [] + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + def test_pinned_tail_survives_preamble_clip() -> None: """A clipped agent stream must not hide the pinned verb spinner: the spinner text stays visible *after* the clip hint instead of being covered by it.""" @@ -264,7 +322,11 @@ def test_background_status_splits_verb_and_count_styles(monkeypatch) -> None: muted_style = f"fg:{get_tui_tokens('dark').muted}" shimmer_styles = {style.lower() for style, text in fragments if text.strip(" …")} - assert {"fg:#e6b450", "fg:#ebc46e", "fg:#f3d89a"} <= shimmer_styles + assert { + f"fg:{_SHIMMER_BASE.lower()}", + f"fg:{_SHIMMER_MID.lower()}", + f"fg:{_SHIMMER_HIGHLIGHT.lower()}", + } <= shimmer_styles assert any(style == muted_style and "2 background agents" in text for style, text in fragments) assert all(style != "ansicyan" for style, _ in fragments) diff --git a/tests/web/test_config_api_thinking.py b/tests/web/test_config_api_thinking.py new file mode 100644 index 00000000..d5c2a510 --- /dev/null +++ b/tests/web/test_config_api_thinking.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from pythinker_code.config import Config +from pythinker_code.web.api import config as config_api +from pythinker_code.web.api.config import UpdateGlobalConfigRequest, update_global_config + + +@pytest.fixture +def saved_config(monkeypatch) -> dict[str, Config]: + """Stub load/save so the PATCH merge runs against an in-memory Config.""" + store: dict[str, Config] = {} + + def fake_load_config(*_args: object, **_kwargs: object) -> Config: + return store.get("config") or Config() + + def fake_save_config(config: Config, *_args: object, **_kwargs: object) -> None: + store["config"] = config + + monkeypatch.setattr(config_api, "load_config", fake_load_config) + monkeypatch.setattr(config_api, "save_config", fake_save_config) + return store + + +async def _patch(request: UpdateGlobalConfigRequest) -> None: + http_request = cast( + Any, + SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(restrict_sensitive_apis=False))), + ) + await update_global_config(request, http_request, runner=cast(Any, None)) + + +@pytest.mark.parametrize( + ("thinking", "effort", "expected_thinking", "expected_effort"), + [ + # When effort is provided it is the source of truth and the bool is + # derived from it — a contradictory bool must not win (regression: the + # PATCH used to silently flip these). + (True, "off", False, "off"), + (False, "high", True, "high"), + # When effort is omitted, fall back to the legacy bool. + (True, None, True, "high"), + (False, None, False, "off"), + # Effort alone is enough. + (None, "low", True, "low"), + ], +) +async def test_patch_thinking_effort_is_source_of_truth( + saved_config: dict[str, Config], + thinking: bool | None, + effort: str | None, + expected_thinking: bool, + expected_effort: str | None, +) -> None: + await _patch( + UpdateGlobalConfigRequest( + default_thinking=thinking, + default_thinking_effort=cast(Any, effort), + restart_running_sessions=False, + ) + ) + config = saved_config["config"] + assert config.default_thinking is expected_thinking + assert config.default_thinking_effort == expected_effort + + +async def test_patch_then_get_round_trips_effort(saved_config: dict[str, Config]) -> None: + await _patch( + UpdateGlobalConfigRequest( + default_thinking_effort=cast(Any, "medium"), restart_running_sessions=False + ) + ) + snapshot = await config_api.get_global_config() + assert snapshot.default_thinking_effort == "medium" + assert snapshot.default_thinking is True