Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8004650
test(auth): guard committed login-brand assets against source drift
elkaix Jun 1, 2026
2e43871
fix(auth): fail soft when a browser-login brand asset is missing
elkaix Jun 1, 2026
f38122b
feat(approval): deliberate before destructive auto-approved actions
elkaix Jun 1, 2026
d999280
feat(ui): thinking-level cycle + frame-color helpers for Shift+Tab
elkaix Jun 1, 2026
eade223
fix(tui): avoid prompt resize artifacts
elkaix Jun 1, 2026
f2b51a4
feat(ui): continue thinking effort selector port
elkaix Jun 1, 2026
97d036e
docs: spec for shimmer traveling-waves redesign
elkaix Jun 2, 2026
b8762e6
feat(ui): traveling-wave shimmer with center-out splash
elkaix Jun 2, 2026
60d1feb
feat(ui): silver shimmer sheen over muted orange-yellow verb
elkaix Jun 2, 2026
c8aa983
fix(recap): summarize turn outcome instead of opening intent
elkaix Jun 2, 2026
0148247
feat(thinking): support minimal reasoning effort across providers
elkaix Jun 2, 2026
4e58fc2
feat(thinking): make thinking effort a first-class config and runtime…
elkaix Jun 2, 2026
39289cc
feat(ui): cycle thinking effort with Shift+Tab and color the prompt b…
elkaix Jun 2, 2026
243229b
feat(auto): auto-deliberate policy with blind advisor for AskUserQues…
elkaix Jun 2, 2026
12f06fd
fix(ui): render reports as padded panels and preserve report-fence seams
elkaix Jun 2, 2026
bfa3433
fix(ui): keep status shimmer animating during quiet wire periods
elkaix Jun 2, 2026
de0191a
feat(shell): no-arg /logout selector and provider login status
elkaix Jun 2, 2026
c4d20b3
fix(agent): include base_prompt in run-agents fingerprint
elkaix Jun 2, 2026
e968a78
test(ui): align compaction-seam and recap spacing expectations
elkaix Jun 2, 2026
d25d401
docs(changelog): note thinking effort controls
elkaix Jun 2, 2026
158ee96
test(ui): address report fence review feedback
elkaix Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
130 changes: 130 additions & 0 deletions docs/superpowers/specs/2026-06-01-shimmer-traveling-waves-design.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions packages/pythinker-core/tests/test_anthropic_thinking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
9 changes: 6 additions & 3 deletions packages/pythinker-core/tests/test_openai_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
13 changes: 13 additions & 0 deletions src/pythinker_code/acp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -380,24 +381,36 @@ 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
self.sessions[session_id] = (acp_session, model_id_conv)

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:
Expand Down
Loading
Loading