Skip to content

Commit 2495020

Browse files
committed
feat(tui): static-grey input border with a top-right effort label
Stop recoloring the whole input border by thinking effort. The border is now one static frame grey at every level; the effort signal moves to a small label flushed right on the input's top border — a level-colored dot (off->max: slate->blue->teal->amber->orange->red) plus the muted level word. The dot carries the cold->hot color at full strength (it's a single glyph), while the word uses a muted class so it never competes with the typed text. The label is hidden for native-thinking models (always_thinking with no user dial) and non-thinking models, and the rule auto-shortens by the measured label width so the top line never wraps. _prompt_separator_style no longer borrows thinking_frame_style, so all input separators (top border and footer) render the same static frame grey.
1 parent 2aec0e4 commit 2495020

4 files changed

Lines changed: 107 additions & 10 deletions

File tree

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 moved off the input border into a top-right label.** The input box border is now one static frame grey at every effort level instead of recoloring the whole bar cold→hot. The effort is shown as a small label flushed to the right of the input's top border — a level-colored dot (slate→blue→teal→amber→orange→red as `off→max`) plus the muted level word — so the dial is still glanceable without tinting the typing area. The label is hidden entirely for native-thinking models (`always_thinking`, no user dial) and non-thinking models, and the rule auto-shortens by the label width so the line never wraps.
1819
- **Reasoning-effort control for Qwen native-thinking models on OpenCode Go.** Qwen3.x/3.7 models (e.g. `qwen3.7-max`) served through the OpenCode Go Anthropic-shaped route now carry the controllable `thinking` capability, so their reasoning effort is user-selectable instead of fixed. Effort maps onto the standard Anthropic `thinking` block (`{"type": "enabled", "budget_tokens": N}` / `{"type": "disabled"}`) that Alibaba Model Studio's Anthropic-compatible endpoint accepts, clamping to the budget-safe `low`/`medium`/`high` range. The Alibaba plan already exposed Qwen thinking effort; this brings the OpenCode Go plan to parity. Unlike GLM/MiniMax (always-on reasoning), Qwen is hybrid, so effort can also be turned off.
1920
- **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default.
2021

src/pythinker_code/ui/shell/prompt.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
)
8181
from pythinker_code.ui.shell.spacing import ensure_prompt_newline
8282
from pythinker_code.ui.shell.spinner_words import spinner_message
83-
from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors, thinking_frame_style
83+
from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors, thinking_dot_style
8484
from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens
8585
from pythinker_code.ui.tui_config import is_card_style
8686
from pythinker_code.utils.clipboard import (
@@ -2441,12 +2441,47 @@ def _supports_thinking_effort(self) -> bool:
24412441
def _prompt_separator_style(self, fallback: str) -> str:
24422442
if getattr(self, "_mode", PromptMode.AGENT) != PromptMode.AGENT:
24432443
return fallback
2444-
if not self._supports_thinking_effort():
2445-
# Non-effort models use the standard input frame color (#3A506D in dark mode)
2446-
# instead of borrowing a thinking level color.
2447-
return "class:compact-input.frame"
2444+
# The input border is one static frame color regardless of thinking
2445+
# effort; the effort signal lives in the top-border label instead
2446+
# (see _effort_label_fragments) rather than recoloring the whole bar.
2447+
return "class:compact-input.frame"
2448+
2449+
def _effort_label_fragments(self) -> list[tuple[str, str]]:
2450+
"""Dot + level label shown at the right end of the input's top border.
2451+
2452+
Returns ``[]`` when there is no effort to choose: non-AGENT modes,
2453+
non-thinking models, and native-thinking models (``always_thinking``
2454+
without a user 'thinking' dial). The dot carries the cold→hot level
2455+
color; the word stays muted so it never competes with the input.
2456+
"""
2457+
if getattr(self, "_mode", PromptMode.AGENT) != PromptMode.AGENT:
2458+
return []
2459+
if self._uses_native_thinking() or not self._supports_thinking_effort():
2460+
return []
24482461
level = self._current_thinking_effort()
2449-
return thinking_frame_style(level) or fallback
2462+
return [
2463+
(thinking_dot_style(level), "● "),
2464+
("class:compact-input.effort", level),
2465+
]
2466+
2467+
def _render_input_top_border(self, columns: int, fallback: str) -> list[tuple[str, str]]:
2468+
"""Static-grey top border for the input card, effort label flushed right.
2469+
2470+
The rule is shortened by the measured label width so the line never
2471+
wraps; when no label applies it spans the full rule like before.
2472+
"""
2473+
border_style = self._prompt_separator_style(fallback)
2474+
rule = _prompt_rule(columns)
2475+
label = self._effort_label_fragments()
2476+
if not label:
2477+
return [(border_style, rule)]
2478+
gap = 2
2479+
label_width = sum(get_cwidth(ch) for _, text in label for ch in text)
2480+
rule_width = max(0, len(rule) - gap - label_width)
2481+
return [
2482+
(border_style, "─" * rule_width + " " * gap),
2483+
*label,
2484+
]
24502485

24512486
def _thinking_footer_label(self) -> str:
24522487
if self._uses_native_thinking():
@@ -2719,7 +2754,7 @@ def _render_agent_prompt_message(self) -> FormattedText:
27192754
if is_card_style():
27202755
ensure_prompt_newline(fragments)
27212756
tc = get_toolbar_colors()
2722-
fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns)))
2757+
fragments.extend(self._render_input_top_border(columns, tc.separator))
27232758
fragments.append(("", "\n"))
27242759
fragments.append(("", _card_side_indent()))
27252760
else:

src/pythinker_code/ui/theme.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ def _task_browser_style_light() -> PTKStyle:
166166
"compact-input": "",
167167
"compact-input.prompt": "fg:#F4F4F5 bold",
168168
"compact-input.frame": "fg:#3A506D",
169+
# Muted level word in the top-border effort label (the dot carries the color).
170+
"compact-input.effort": "fg:#A3A3A3",
169171
"running-prompt-placeholder": "fg:#A3A3A3 italic",
170172
"running-prompt-separator": "fg:#2B3A52",
171173
# Slash completion menu — selected row gets the same selected-bg as cards.
@@ -204,6 +206,8 @@ def _task_browser_style_light() -> PTKStyle:
204206
"compact-input": "",
205207
"compact-input.prompt": "fg:#213853 bold",
206208
"compact-input.frame": "fg:#495F7C",
209+
# Muted level word in the top-border effort label (the dot carries the color).
210+
"compact-input.effort": "fg:#666666",
207211
"running-prompt-placeholder": "fg:#666666 italic",
208212
"running-prompt-separator": "fg:#C8BEC0",
209213
"slash-completion-menu": "",
@@ -647,3 +651,16 @@ def thinking_frame_style(level: str, *, theme: ThemeName | None = None) -> str:
647651
pole = (255, 255, 255) if name == "light" else (0, 0, 0)
648652
color = to_hex_color(blend(rgb, pole, 0.7))
649653
return f"fg:{color}"
654+
655+
656+
def thinking_dot_style(level: str, *, theme: ThemeName | None = None) -> str:
657+
"""prompt_toolkit style for the small effort *dot* on the input top border.
658+
659+
Unlike :func:`thinking_frame_style` (which dims the color because it paints
660+
a full-width bar), the dot is a single glyph, so it carries the level color
661+
at full strength — the one intentional accent on an otherwise static-grey
662+
border. Returns ``""`` when colors are disabled.
663+
"""
664+
if colors_disabled():
665+
return ""
666+
return f"fg:{thinking_frame_color(level, theme=theme)}"

tests/ui_and_conv/test_prompt_tips.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,8 +605,11 @@ def get_size() -> Any:
605605
) in fragments
606606

607607

608-
def test_card_toolbar_separator_matches_thinking_prompt_color(monkeypatch: Any) -> None:
609-
from pythinker_code.ui.theme import set_active_theme, thinking_frame_style
608+
def test_card_toolbar_separator_is_static_grey_regardless_of_effort(monkeypatch: Any) -> None:
609+
# The input border no longer borrows the thinking-effort color; every
610+
# separator is the one static frame grey (the effort signal moved to the
611+
# top-border label instead).
612+
from pythinker_code.ui.theme import set_active_theme
610613

611614
prompt_session = _make_toolbar_session(
612615
model_name="fast-model", model_capabilities={"thinking"}, tips=[]
@@ -631,11 +634,52 @@ def get_size() -> Any:
631634
fragments = list(prompt_session._render_bottom_toolbar())
632635

633636
assert fragments[0] == (
634-
thinking_frame_style("xhigh", theme="dark"),
637+
"class:compact-input.frame",
635638
shell_prompt._prompt_rule(120),
636639
)
637640

638641

642+
def test_input_top_border_shows_effort_label() -> None:
643+
from prompt_toolkit.utils import get_cwidth
644+
645+
from pythinker_code.ui.theme import set_active_theme, thinking_dot_style
646+
647+
set_active_theme("dark")
648+
session = _make_toolbar_session(
649+
model_name="fast-model", model_capabilities={"thinking"}, tips=[]
650+
)
651+
session._thinking = True
652+
session._thinking_effort = "high"
653+
654+
fragments = session._render_input_top_border(80, "class:fallback")
655+
656+
# Border stays static grey; effort color lives only in the trailing dot.
657+
assert fragments[0][0] == "class:compact-input.frame"
658+
assert fragments[-2] == (thinking_dot_style("high", theme="dark"), "● ")
659+
assert fragments[-1] == ("class:compact-input.effort", "high")
660+
# The whole line stays within the rule budget so it never wraps.
661+
total = sum(get_cwidth(ch) for _, text in fragments for ch in text)
662+
assert total == len(shell_prompt._prompt_rule(80))
663+
664+
665+
def test_input_top_border_hides_effort_label_for_native_and_nonthinking() -> None:
666+
# Native-thinking model (always_thinking, no user dial): no label.
667+
native = _make_toolbar_session(
668+
model_name="MiniMax M2.7", model_capabilities={"always_thinking"}, tips=[]
669+
)
670+
assert native._effort_label_fragments() == []
671+
assert native._render_input_top_border(80, "class:fallback") == [
672+
("class:compact-input.frame", shell_prompt._prompt_rule(80))
673+
]
674+
675+
# Non-thinking model: no label either.
676+
plain = _make_toolbar_session(model_name="fast-model", model_capabilities=set(), tips=[])
677+
assert plain._effort_label_fragments() == []
678+
assert plain._render_input_top_border(80, "class:fallback") == [
679+
("class:compact-input.frame", shell_prompt._prompt_rule(80))
680+
]
681+
682+
639683
def test_card_toolbar_separator_uses_standard_frame_for_non_thinking_models(
640684
monkeypatch: Any,
641685
) -> None:

0 commit comments

Comments
 (0)