Skip to content

Commit 1dd9ed2

Browse files
authored
feat(shell): add thinking effort controls and auto-mode deliberation (#50)
* test(auth): guard committed login-brand assets against source drift web/static/ is a build output (scripts/build_web.py rmtree's + repopulates it from the vite build, whose brand files come from web/public/brand). The two brand assets are force-committed only so the test job — which doesn't run the web build — has the files the OAuth callback reads. Add a guard asserting those committed copies stay byte-identical to web/public/brand, so a brand-source change can't silently leave the branding tests validating a stale fixture. * fix(auth): fail soft when a browser-login brand asset is missing The OAuth callback page embeds icon.svg/favicon.ico as data URIs. These are build outputs (web/static) and normally always present, but a broken build shouldn't crash login over a cosmetic asset. Catch OSError in the data-uri helper, log a warning, and embed an empty source so the callback still renders. Add a test for the degraded path. * feat(approval): deliberate before destructive auto-approved actions Under auto_deliberate, an irreversible shell action (rm -rf, git push --force, git reset --hard, dd, truncate) is bounced once before running -- even under auto/yolo -- so the agent weighs alternatives first. One-shot: the identical re-issue runs, so deliberation never permanently whitelists the command. ApprovalResult gains a deliberation variant whose feedback is not framed as a user rejection. * feat(ui): thinking-level cycle + frame-color helpers for Shift+Tab Pure helpers for the Shift+Tab thinking feature: next_thinking_level() cycles off->minimal->low->medium->high->xhigh->off, and thinking_frame_color/ thinking_frame_style map each level to a prompt border color (grey->blue-> violet->purple) per theme. Keymap + prompt-handler wiring follows. * fix(tui): avoid prompt resize artifacts * feat(ui): continue thinking effort selector port * docs: spec for shimmer traveling-waves redesign * feat(ui): traveling-wave shimmer with center-out splash Restructure the active-work shimmer into a four-phase loop: a wave sweeps right-to-left, splashes outward from the middle, sweeps back left-to-right, splashes again, then repeats. Replaces the previous single-direction repeating sweep. Stays purely time-derived so the prompt, activity tree, and pinned-todo renderers animate in sync. * feat(ui): silver shimmer sheen over muted orange-yellow verb Recolor the active-work shimmer: highlight is now silver (#D8DCE2) instead of violet, and the verb-spinner/pinned-todo base is a muted orange-yellow (#D49E5A) instead of golden amber. Point palette tests at the motion constants so future shade tweaks don't churn test literals. * fix(recap): summarize turn outcome instead of opening intent The per-turn '※ recap:' line quoted the assistant's first sentence, which is always an intent preamble ('I'll start by gathering...') rather than what the turn accomplished. Add an outcome-sentence heuristic that skips intent/offer/question/path-noise lines and prefers the closing summary, and append factual deltas (N files changed · M steps). Wire the live view to count files changed per turn from diff display blocks. Also improve /recap: session bullets lead with the session outcome instead of the first user message, and a single short no-op session is reported plainly as a light day rather than padded into a bullet list. * feat(thinking): support minimal reasoning effort across providers Make "minimal" a first-class ThinkingEffort: OpenAI round-trips it natively, the Pythinker provider preserves it instead of collapsing to low, Anthropic clamps it down to its floor (low) rather than up to high, and Gemini maps it to its lowest thinking level/budget. * feat(thinking): make thinking effort a first-class config and runtime setting Add a persisted default_thinking_effort alongside the legacy default_thinking bool, with the effort string as the source of truth (falling back to the bool only for pre-existing configs). create_llm now takes an explicit effort, clamps it to model capabilities, preserves levels like xhigh/minimal instead of collapsing every enabled request to high, and threads the effort through the CLI, ACP, review, web API, subagents, login flows, setup, and the /model and /thinking selectors. Always-thinking models surface native reasoning instead of an effort dial. * feat(ui): cycle thinking effort with Shift+Tab and color the prompt by effort Repurpose Shift+Tab from plan-mode toggle to a thinking-effort cycle over the model's available levels, with a toast and telemetry. The prompt separator and bottom-toolbar label now reflect the active effort (or "native reasoning"), and the effort color ramp moves from a grey/violet scale to a cool-to-warm slate→pink ramp. Tips and keybinding help updated to match. * feat(auto): auto-deliberate policy with blind advisor for AskUserQuestion Add an "auto_deliberate" ask-user policy: in auto mode, instead of silently dismissing AskUserQuestion, run an independent tool-less advisor that blind-ranks the agent's own options and hand the verdict back so the agent self-decides. Destructive auto-approved actions are bounced once for deliberation via a tool-agnostic classifier, and the auto-mode prompt invites the tool at genuine forks under this policy. * fix(ui): render reports as padded panels and preserve report-fence seams Render report blocks as a rounded, padded Rich panel (a standalone reading surface) with hanging-indented wrapped locations, and keep a one-row seam when a report fence follows streamed prose. Ask-question cards gain blank-row separation between header and questions. * fix(ui): keep status shimmer animating during quiet wire periods Add a prompt-scoped refresh loop so the pinned-status shimmer stays frame-based when wire events are sparse (e.g. a long-running subagent), honoring reduced-motion with a slower interval. * feat(shell): no-arg /logout selector and provider login status Map each login/logout entry to the managed provider keys that signal it is configured, so /login and /logout report real status and a bare /logout opens a selector over only the logged-in providers (with a single OpenAI entry covering both OAuth and API-key credentials). * fix(agent): include base_prompt in run-agents fingerprint Two RunAgents calls differing only in base_prompt produced the same fingerprint; include it so distinct launches are not deduplicated. * test(ui): align compaction-seam and recap spacing expectations Update rendering tests to match the committed compaction/recap seam behavior: compaction commits a leading blank row plus block, and turn recaps are framed by blank rows. * docs(changelog): note thinking effort controls * test(ui): address report fence review feedback
1 parent ccdcb0d commit 1dd9ed2

90 files changed

Lines changed: 2900 additions & 306 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

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

1616
## Unreleased
1717

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Shimmer traveling-waves redesign
2+
3+
**Date:** 2026-06-01
4+
**Status:** Approved design, pending implementation
5+
**Scope:** `src/pythinker_code/ui/shell/motion.py` (plus tests)
6+
7+
## Problem
8+
9+
The active-work shimmer (`_shimmer_segments`) sweeps a single bright highlight
10+
right-to-left across a label, then jumps back and repeats in the same direction.
11+
It loops in one direction only — there is no sense of the light bouncing or
12+
reaching the end of the word.
13+
14+
We want the shimmer to read like **traveling waves**: a wave crosses the word,
15+
splashes outward from the middle when it reaches the end, then a wave travels
16+
back the other way, splashes again, and repeats.
17+
18+
## Goal
19+
20+
Restructure the per-character shimmer into a four-phase loop while keeping:
21+
22+
- the existing 3-color palette (`#D49E5A` muted orange-yellow base / `#E2C18A`
23+
warm sheen-trail mid / `#D8DCE2` silver highlight) and
24+
`_SHIMMER_INTERVAL_S = 0.22` tick;
25+
- the **purely time-derived** model — every frame is a function of `elapsed_s`
26+
alone, so the prompt, activity tree, and pinned-todo renderers stay in sync
27+
with no shared animation state;
28+
- the public surface: `shimmer_text`, `shimmer_prompt_fragments`, and the
29+
per-char path inside `activity_status_line` call the same engine with no
30+
signature changes;
31+
- the `colors_disabled()` (plain text) and reduced-motion (static base amber)
32+
short-circuits exactly as today.
33+
34+
## Animation cycle
35+
36+
`L = len(label)`. One loop is four phases, indexed by
37+
`frame = int(max(0.0, elapsed_s) / 0.22)`, `phase_index = frame % CYCLE_LEN`.
38+
39+
| Phase | Name | Behavior | Frames |
40+
|-------|-----------------|-------------------------------------------------------------|------------------|
41+
| A | Wave → (R→L) | Current sweep, unchanged: violet head + asymmetric coral trail | `L + 6` |
42+
| B | Splash | Wave blooms from center char outward to both edges, settles | `ceil(L/2) + 3` |
43+
| C | Wave ← (L→R) | Mirror of A: head travels the other way, trail flips side | `L + 6` |
44+
| D | Splash | Same center-out bloom as B | `ceil(L/2) + 3` |
45+
46+
`CYCLE_LEN = 2*(L + 6) + 2*(ceil(L/2) + 3)`. After phase D the loop returns to A.
47+
48+
### Phase A — wave right-to-left (preserve current look)
49+
50+
Unchanged from today: `head = L + 2 - local_phase`; for each non-space char at
51+
index `i`, `offset = i - head`:
52+
53+
- `offset == 0` → highlight
54+
- `offset in (-1, 1, 2, 3)` → mid (asymmetric trailing edge)
55+
- else → base
56+
57+
### Phase C — wave left-to-right (mirror)
58+
59+
`head` travels from the left edge to past the right edge as `local_phase`
60+
increases. The trail is mirrored to the opposite side so the sheen still trails
61+
*behind* the direction of travel:
62+
63+
- `offset == 0` → highlight
64+
- `offset in (1, -1, -2, -3)` → mid
65+
- else → base
66+
67+
### Phase B / D — splash (center-out traveling wave)
68+
69+
`center = (L - 1) / 2` (fractional for even `L`). On local splash frame `f`
70+
(0-based), wavefront radius `r = f`. For each non-space char at index `i`,
71+
`d = abs(i - center)`. The wavefront is a half-cell band so odd and even
72+
lengths behave identically:
73+
74+
- `r - 0.5 <= d <= r + 0.5` → highlight — the expanding wavefront
75+
- `d < r - 0.5` → mid — already-filled interior
76+
- `d > r + 0.5` → base — not yet reached
77+
78+
For even `L` the two center chars (`d == 0.5`) light up together on `f == 0`;
79+
for odd `L` the single center char (`d == 0`) lights up on `f == 0`.
80+
81+
The final settle frames (after the wavefront passes both edges) paint the whole
82+
word base amber, giving a brief calm beat before the next wave launches.
83+
84+
Spaces remain uncolored (`None`) in every phase, exactly as today.
85+
86+
## Implementation shape
87+
88+
Refactor `_shimmer_segments(label, elapsed_s, *, reduced_motion)` into a small
89+
dispatcher:
90+
91+
- keep the early returns (`not label`, `colors_disabled`, reduced-motion);
92+
- compute `L`, the four phase lengths, `CYCLE_LEN`, and `phase_index`;
93+
- delegate to one of two helpers that return a `list[str | None]` of per-char
94+
colors:
95+
- `_wave_colors(chars, local_phase, direction)` — phases A and C;
96+
- `_splash_colors(chars, local_phase)` — phases B and D;
97+
- coalesce equal-color runs into `(color, text)` segments (existing logic).
98+
99+
No changes to `shimmer_text`, `shimmer_prompt_fragments`,
100+
`shimmer_spinner_style`, or any call site.
101+
102+
## Edge cases
103+
104+
- `L == 0``[]` (existing guard).
105+
- `L == 1``center == 0`, splash highlights the single char on `f == 0` then
106+
settles; waves degenerate gracefully (single char cycles base/mid/highlight).
107+
- Labels with spaces / multi-word ("Reticulating splines") → positional math is
108+
unaffected; spaces stay `None`.
109+
110+
## Verification (TDD)
111+
112+
New tests in `tests/ui_and_conv/test_shell_motion_shimmer.py`:
113+
114+
1. **Splash originates at center and widens** — at a splash-phase frame, the
115+
highlighted indices are centered and the highlighted/filled span grows over
116+
consecutive frames.
117+
2. **Phase C trail is mirrored vs phase A** — for a head at the same offset, the
118+
mid-colored trail sits on the opposite side.
119+
3. **Cycle returns to start** — colors at `frame` and `frame + CYCLE_LEN`
120+
(for a fixed label) are identical.
121+
4. **Palette + plain-text invariants preserved** — existing three-color and
122+
reduced-motion assertions still pass.
123+
124+
Plus: `make check-pythinker-code` (ruff check + ruff format) green.
125+
126+
## Out of scope
127+
128+
- `shimmer_spinner_style` (single-color whole-word path) keeps its current
129+
simple 4-step palette cycle.
130+
- No new config flags, no palette changes, no timing knobs exposed.

packages/pythinker-core/src/pythinker_core/chat_provider/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,13 @@ def input(self) -> int:
119119
return self.input_other + self.input_cache_read + self.input_cache_creation
120120

121121

122-
type ThinkingEffort = Literal["off", "low", "medium", "high", "xhigh", "max"]
122+
type ThinkingEffort = Literal["off", "minimal", "low", "medium", "high", "xhigh", "max"]
123123
"""The effort level for thinking.
124124
125+
``minimal`` is the lowest user-facing reasoning level and maps to providers
126+
that support it natively (for example OpenAI reasoning_effort) or to the
127+
smallest available budget/effort otherwise.
128+
125129
Support for levels above ``high`` varies by provider:
126130
127131
- **Anthropic**: ``xhigh`` is accepted only on Claude Opus 4.7; ``max`` is

packages/pythinker-core/src/pythinker_core/chat_provider/openai_common.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffo
119119
match effort:
120120
case "off":
121121
return None
122+
case "minimal":
123+
return "minimal"
122124
case "low":
123125
return "low"
124126
case "medium":
@@ -137,7 +139,9 @@ def thinking_effort_to_reasoning_effort(effort: ThinkingEffort) -> ReasoningEffo
137139

138140
def reasoning_effort_to_thinking_effort(effort: ReasoningEffort) -> ThinkingEffort:
139141
match effort:
140-
case "low" | "minimal":
142+
case "minimal":
143+
return "minimal"
144+
case "low":
141145
return "low"
142146
case "medium":
143147
return "medium"

packages/pythinker-core/src/pythinker_core/chat_provider/pythinker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ def thinking_effort(self) -> ThinkingEffort | None:
140140
if reasoning_effort is None:
141141
return None
142142
match reasoning_effort:
143+
case "minimal":
144+
return "minimal"
143145
case "low":
144146
return "low"
145147
case "medium":
@@ -197,6 +199,8 @@ def with_thinking(self, effort: ThinkingEffort) -> Self:
197199
match effort:
198200
case "off":
199201
reasoning_effort = None
202+
case "minimal":
203+
reasoning_effort = "minimal"
200204
case "low":
201205
reasoning_effort = "low"
202206
case "medium":

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/anthropic.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ def _clamp_effort(effort: "ThinkingEffort", model: str) -> "ThinkingEffort":
175175
return effort
176176
if effort in _supported_efforts(model):
177177
return effort
178+
if effort == "minimal":
179+
# Anthropic has no 'minimal' effort; 'low' is the floor of every
180+
# _supported_efforts() set. Map down to it rather than clamping up to
181+
# 'high', which would request a far larger budget than the user asked for.
182+
return "low"
178183
return "high"
179184

180185

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/google_genai.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ def with_thinking(self, effort: "ThinkingEffort") -> Self:
178178
case "off":
179179
# use default thinking config
180180
pass
181-
case "low":
181+
case "minimal" | "low":
182+
# Gemini has no 'minimal'; map to its lowest level.
182183
thinking_config.thinking_level = ThinkingLevel.LOW
183184
case "medium":
184185
# FIXME: medium not supported yet, use high
@@ -191,7 +192,8 @@ def with_thinking(self, effort: "ThinkingEffort") -> Self:
191192
case "off":
192193
thinking_config.thinking_budget = 0
193194
thinking_config.include_thoughts = False
194-
case "low":
195+
case "minimal" | "low":
196+
# Gemini has no 'minimal'; use its lowest thinking budget.
195197
thinking_config.thinking_budget = 1024
196198
thinking_config.include_thoughts = True
197199
case "medium":

packages/pythinker-core/tests/api_snapshot_tests/test_pythinker.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,3 +467,21 @@ async def test_pythinker_with_extra_body_non_thinking_key_shallow_merge():
467467
pass
468468
body = json.loads(mock.calls.last.request.content.decode())
469469
assert body["custom"] == snapshot({"b": 2})
470+
471+
472+
def test_with_thinking_minimal_round_trips():
473+
from pythinker_core.chat_provider.pythinker import Pythinker
474+
475+
provider = Pythinker(model="pythinker-ai", api_key="test-key", stream=False).with_thinking(
476+
"minimal"
477+
)
478+
assert provider.thinking_effort == "minimal"
479+
480+
481+
def test_with_thinking_low_unchanged():
482+
from pythinker_core.chat_provider.pythinker import Pythinker
483+
484+
provider = Pythinker(model="pythinker-ai", api_key="test-key", stream=False).with_thinking(
485+
"low"
486+
)
487+
assert provider.thinking_effort == "low"

packages/pythinker-core/tests/test_anthropic_thinking.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ def test_supports_adaptive_thinking(model: str, expected: bool) -> None:
103103
("claude-opus-4-7", "low", "low"),
104104
("claude-opus-4-6", "medium", "medium"),
105105
("claude-sonnet-4-20250514", "low", "low"),
106+
# minimal has no Anthropic equivalent; clamp DOWN to the lowest ('low'),
107+
# not up to 'high' (which would request a far larger budget).
108+
("claude-opus-4-5", "minimal", "low"),
109+
("claude-sonnet-4-6", "minimal", "low"),
110+
("claude-opus-4-7", "minimal", "low"),
106111
# Future 4.8+ inherits Opus 4.7-like behavior only if name signals opus-4-7+
107112
# 4.8 is not automatically assumed to support xhigh; only guaranteed max.
108113
("claude-opus-4-8", "xhigh", "high"),

packages/pythinker-core/tests/test_openai_common.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@
2222
class TestThinkingEffortMapping:
2323
"""OpenAI's reasoning_effort accepts: none, minimal, low, medium, high, xhigh
2424
(xhigh added for models after gpt-5.1-codex-max). Pythinker Core's ThinkingEffort
25-
is: off, low, medium, high, xhigh, max. The bidirectional mapping must
26-
preserve xhigh round-trip and clamp max sensibly.
25+
is: off, minimal, low, medium, high, xhigh, max. The bidirectional mapping must
26+
preserve the minimal and xhigh round-trips and clamp max sensibly.
2727
"""
2828

2929
@pytest.mark.parametrize(
3030
"thinking_effort,expected_reasoning",
3131
[
3232
("off", None),
33+
# OpenAI supports minimal natively — first-class round-trip.
34+
("minimal", "minimal"),
3335
("low", "low"),
3436
("medium", "medium"),
3537
("high", "high"),
@@ -56,7 +58,8 @@ def test_thinking_to_reasoning(
5658
[
5759
(None, "off"),
5860
("none", "off"),
59-
("minimal", "low"),
61+
# OpenAI supports minimal natively — first-class round-trip.
62+
("minimal", "minimal"),
6063
("low", "low"),
6164
("medium", "medium"),
6265
("high", "high"),

0 commit comments

Comments
 (0)