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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

- **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.

## 0.28.0 (2026-05-31)
Expand Down
88 changes: 88 additions & 0 deletions src/pythinker_code/auth/browser_login_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from __future__ import annotations

import base64
import html
from functools import lru_cache
from pathlib import Path

_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"


# Bounded: only the two brand assets below are ever passed in; the cap keeps a
# 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")
return f"data:{media_type};base64,{encoded}"


def browser_login_logo_data_uri() -> str:
return browser_login_asset_data_uri(_PYTHINKER_LOGO_PATH, "image/svg+xml")


def browser_login_favicon_data_uri() -> str:
return browser_login_asset_data_uri(_PYTHINKER_FAVICON_PATH, "image/x-icon")


def build_browser_login_result_html(
*,
ok: bool,
success_title: str,
failure_title: str,
success_heading: str,
failure_heading: str,
success_body: str,
failure_body: str | None,
fallback_failure_body: str,
) -> str:
title = success_title if ok else failure_title
heading = success_heading if ok else failure_heading
body = success_body if ok else failure_body
escaped_title = html.escape(title)
escaped_heading = html.escape(heading)
escaped_body = html.escape(body or fallback_failure_body)
favicon = html.escape(browser_login_favicon_data_uri(), quote=True)
logo = html.escape(browser_login_logo_data_uri(), quote=True)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<link rel="icon" type="image/x-icon" href="{favicon}">
<style>
:root {{ color-scheme: light dark; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1e293b 0, #0f172a 42%, #020617 100%);
color: #f8fafc;
}}
main {{
width: min(440px, calc(100vw - 48px));
padding: 40px 32px;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 28px;
background: rgba(15, 23, 42, 0.82);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
text-align: center;
}}
.logo {{ width: 82px; height: auto; margin-bottom: 22px; }}
h1 {{ margin: 0 0 12px; font-size: 2rem; line-height: 1.15; }}
p {{ margin: 0; color: #cbd5e1; font-size: 1.05rem; line-height: 1.6; }}
</style>
</head>
<body>
<main>
<img class="logo" src="{logo}" alt="Pythinker logo">
<h1>{escaped_heading}</h1>
<p>{escaped_body}</p>
</main>
</body>
</html>"""
75 changes: 10 additions & 65 deletions src/pythinker_code/auth/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import base64
import binascii
import hashlib
import html
import json
import secrets
import time
Expand All @@ -17,6 +16,7 @@
from pydantic import SecretStr

from pythinker_code.auth import OPENAI_API_PLATFORM_ID, OPENAI_CHATGPT_PLATFORM_ID
from pythinker_code.auth.browser_login_page import build_browser_login_result_html
from pythinker_code.auth.oauth import (
OAuthError,
OAuthEvent,
Expand Down Expand Up @@ -228,72 +228,17 @@ def _build_authorize_url(
return f"{authorize_url}?{query}"


_PYTHINKER_CALLBACK_LOGO_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Pythinker">
<rect width="64" height="64" rx="16" fill="#0f172a"/>
<rect x="12" y="20" width="40" height="28" rx="10" fill="#f9f2f5"/>
<path d="M20 48h24l5 10H15z" fill="#ee9983"/>
<circle cx="25" cy="34" r="6" fill="#afe3f1" stroke="#213853" stroke-width="4"/>
<circle cx="39" cy="34" r="6" fill="#afe3f1" stroke="#213853" stroke-width="4"/>
<path d="M27 45h10" stroke="#213853" stroke-width="4" stroke-linecap="round"/>
<path d="M32 20V9" stroke="#213853" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="8" r="5" fill="#ee9983"/>
</svg>
""".strip()


def _callback_html(*, ok: bool, message: str | None) -> str:
title = "Pythinker logged in" if ok else "Pythinker login failed"
heading = "You're logged in to Pythinker" if ok else "Pythinker login failed"
body = "You can close this tab and return to Pythinker." if ok else message
escaped_title = html.escape(title)
escaped_heading = html.escape(heading)
escaped_body = html.escape(body or "OpenAI login failed.")
favicon = html.escape(
"data:image/svg+xml," + _PYTHINKER_CALLBACK_LOGO_SVG.replace("#", "%23"),
quote=True,
return build_browser_login_result_html(
ok=ok,
success_title="Pythinker logged in",
failure_title="Pythinker login failed",
success_heading="You're logged in to Pythinker",
failure_heading="Pythinker login failed",
success_body="You can close this tab and return to Pythinker.",
failure_body=message,
fallback_failure_body="OpenAI login failed.",
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<link rel="icon" href="{favicon}">
<style>
:root {{ color-scheme: light dark; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1e293b 0, #0f172a 42%, #020617 100%);
color: #f8fafc;
}}
main {{
width: min(440px, calc(100vw - 48px));
padding: 40px 32px;
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 28px;
background: rgba(15, 23, 42, 0.82);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
text-align: center;
}}
.logo {{ width: 88px; height: 88px; margin-bottom: 22px; }}
h1 {{ margin: 0 0 12px; font-size: 2rem; line-height: 1.15; }}
p {{ margin: 0; color: #cbd5e1; font-size: 1.05rem; line-height: 1.6; }}
</style>
</head>
<body>
<main>
{_PYTHINKER_CALLBACK_LOGO_SVG.replace("<svg ", '<svg class="logo" ')}
<h1>{escaped_heading}</h1>
<p>{escaped_body}</p>
</main>
</body>
</html>"""


async def _handle_browser_callback(
Expand Down
4 changes: 4 additions & 0 deletions src/pythinker_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ class TUIConfig(BaseModel):
"Set false or export PYTHINKER_DISABLE_PROMPT_HISTORY=1 for sensitive sessions."
),
)
turn_recaps: bool = Field(
default=True,
description="Show a compact recap line after completed interactive shell turns.",
)


class MCPConfig(BaseModel):
Expand Down
79 changes: 76 additions & 3 deletions src/pythinker_code/hooks/engine.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import inspect
import re
import time
import uuid
Expand All @@ -16,13 +17,77 @@
type OnTriggered = Callable[[str, str, int], None]
"""(event, target, hook_count) -> None"""

type OnResolved = Callable[[str, str, str, str, int], None]
"""(event, target, action, reason, duration_ms) -> None"""
type OnResolved = Callable[..., None]
"""(event, target, action, reason, duration_ms[, outputs]) -> None.

Intentionally variadic: ``_resolved_callback_accepts_outputs`` inspects each
concrete callable at runtime and calls it with 5 or 6 positional args, so both
legacy 5-arg subscribers and opt-in 6-arg subscribers are valid. A stricter
Protocol/overload type was tried and rejected — it statically excludes one of
the two arities the runtime deliberately supports (see tests/hooks)."""

type OnWireHookRequest = Callable[[WireHookHandle], Awaitable[None]]
"""Called when a wire hook needs client handling. The callback should send
the request over the wire and resolve the handle when the client responds."""

_MAX_HOOK_OUTPUT_CHARS = 12_000


def _truncate_hook_output(text: str) -> tuple[str, bool]:
if len(text) <= _MAX_HOOK_OUTPUT_CHARS:
return text, False
return text[:_MAX_HOOK_OUTPUT_CHARS].rstrip() + "\n...[truncated]", True


def _hook_outputs_for_wire(results: list[HookResult]) -> tuple[dict[str, Any], ...]:
outputs: list[dict[str, Any]] = []
for result in results:
stdout, stdout_truncated = _truncate_hook_output(result.stdout)
stderr, stderr_truncated = _truncate_hook_output(result.stderr)
if not stdout and not stderr and not result.timed_out:
continue
outputs.append(
{
"stdout": stdout,
"stderr": stderr,
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"truncated": stdout_truncated or stderr_truncated,
}
)
return tuple(outputs)


def _resolved_callback_accepts_outputs(callback: OnResolved) -> bool:
try:
signature = inspect.signature(callback)
except (TypeError, ValueError):
return False
parameters = tuple(signature.parameters.values())
if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in parameters):
return True
positional_kinds = {
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
}
positional = [param for param in parameters if param.kind in positional_kinds]
return len(positional) >= 6


def _call_on_resolved(
callback: OnResolved,
event: str,
target: str,
action: str,
reason: str,
duration_ms: int,
outputs: tuple[dict[str, Any], ...],
) -> None:
if _resolved_callback_accepts_outputs(callback):
callback(event, target, action, reason, duration_ms, outputs)
else:
callback(event, target, action, reason, duration_ms)


@dataclass
class WireHookSubscription:
Expand Down Expand Up @@ -332,7 +397,15 @@ async def _execute_hooks(
# --- HookResolved ---
if self._on_resolved:
try:
self._on_resolved(event, matcher_value, action, reason, duration_ms)
_call_on_resolved(
self._on_resolved,
event,
matcher_value,
action,
reason,
duration_ms,
_hook_outputs_for_wire(results),
)
except Exception as e:
from pythinker_code.telemetry.errors import report_handled_error

Expand Down
Loading