Skip to content

Commit ccdcb0d

Browse files
authored
feat(shell): polish prompt rendering and recaps (#49)
* feat(shell): polish prompt rendering and recaps * docs: update changelog for shell recaps * fix(tui): wrap markdown prose on word boundaries * fix(ui): correct huge new-file write render + harden ANSI/recap/hooks Apply verified fixes from the branch code-diff review: - write/_file_diff: a brand-new file >10k lines produced a one-line diff summary ("removed 1 / added 1") instead of "Wrote N lines". The summary block's "- (0 lines)" line made preview.removed==1, so the diff path was taken regardless of _is_existing_file_diff. Add DiffPreview.is_new_file (every block's old side empty) and gate the diff path on it, fixing both routing clauses. Pre-existing bug; validated across new/existing × huge/small. - render_utils.sanitize_ansi: strip 8-bit C1 controls (0x80-0x9F), incl. the single-byte CSI/OSC/PM/APC introducers terminals still interpret. - _live_view: route the per-turn recap banner through sanitize_ansi. - wire/types.HookOutput: bound stdout/stderr with max_length (12_032, headroom for the engine's truncation marker) so oversized payloads fail at the wire boundary instead of being silently accepted. - auth/browser_login_page: cap the asset data-uri cache (lru_cache maxsize=16). - hooks/engine: document why OnResolved stays Callable[..., None] (the runtime intentionally supports both 5- and 6-arg callbacks; a strict Protocol/overload rejects one valid arity — confirmed via the type gate). - tests: backfill session_recap (3 -> 24 tests, incl. summarize_session via a fake session), huge-new-file write render, and C1 ANSI stripping. * feat(shell): classify destructive commands + address review comments - soul/permission: add shell_destructive_reason() to flag irreversible commands (recursive force-delete, force-push, hard reset, raw disk writes) so auto mode routes them into a deliberation turn instead of auto-approving. Reuses the shell_mutation_reason tokenizer (shlex split, wrapper unwrap, git-subcommand extraction) for wrapper/quote/chain hardening. - CodeRabbit review fixes: annotate /recap slash command with -> None; show hook "timed out" status even when the hook produced output; make the streaming-block test helper fail loudly when the label is missing. - tests: cover destructive classification, the recap/visualize paths, and the hardened test helper. * style: apply ruff format to test_visualize_running_prompt * test: silence unused kwargs in hook-timeout test lambda (CodeRabbit) * test: silence unused kwargs in second live-view print lambda (CodeRabbit) * fix(auth): commit browser-login brand assets needed by login page icon.svg and favicon.ico under web/static/brand are read at runtime by auth/browser_login_page to embed branding in the OAuth callback HTML, but the whole web/static tree is gitignored — so CI's clean checkout lacked them and test_openai_callback_html_* failed with FileNotFoundError (passed locally only because the files exist on disk). Force-add the two required assets so the feature and its tests work from a fresh clone. * test(web): gate static-cache tests on index.html, not dir existence Committing the brand assets makes web/static/ exist in CI for the first time, which previously un-skipped test_index_html_has_no_cache_header — but the built frontend (index.html, assets/) still isn't present, so GET / would 404. Guard on index.html existence instead, matching the 'web static assets not built' intent: runs where the app is built (local/prod), skips where only brand assets exist (CI).
1 parent 691cc49 commit ccdcb0d

50 files changed

Lines changed: 1973 additions & 130 deletions

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

2021
## 0.28.0 (2026-05-31)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import html
5+
from functools import lru_cache
6+
from pathlib import Path
7+
8+
_PYTHINKER_BRAND_DIR = Path(__file__).resolve().parents[1] / "web" / "static" / "brand"
9+
_PYTHINKER_LOGO_PATH = _PYTHINKER_BRAND_DIR / "icon.svg"
10+
_PYTHINKER_FAVICON_PATH = _PYTHINKER_BRAND_DIR / "favicon.ico"
11+
12+
13+
# Bounded: only the two brand assets below are ever passed in; the cap keeps a
14+
# future caller with many distinct paths from leaking memory.
15+
@lru_cache(maxsize=16)
16+
def browser_login_asset_data_uri(path: Path, media_type: str) -> str:
17+
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
18+
return f"data:{media_type};base64,{encoded}"
19+
20+
21+
def browser_login_logo_data_uri() -> str:
22+
return browser_login_asset_data_uri(_PYTHINKER_LOGO_PATH, "image/svg+xml")
23+
24+
25+
def browser_login_favicon_data_uri() -> str:
26+
return browser_login_asset_data_uri(_PYTHINKER_FAVICON_PATH, "image/x-icon")
27+
28+
29+
def build_browser_login_result_html(
30+
*,
31+
ok: bool,
32+
success_title: str,
33+
failure_title: str,
34+
success_heading: str,
35+
failure_heading: str,
36+
success_body: str,
37+
failure_body: str | None,
38+
fallback_failure_body: str,
39+
) -> str:
40+
title = success_title if ok else failure_title
41+
heading = success_heading if ok else failure_heading
42+
body = success_body if ok else failure_body
43+
escaped_title = html.escape(title)
44+
escaped_heading = html.escape(heading)
45+
escaped_body = html.escape(body or fallback_failure_body)
46+
favicon = html.escape(browser_login_favicon_data_uri(), quote=True)
47+
logo = html.escape(browser_login_logo_data_uri(), quote=True)
48+
return f"""<!doctype html>
49+
<html lang="en">
50+
<head>
51+
<meta charset="utf-8">
52+
<meta name="viewport" content="width=device-width, initial-scale=1">
53+
<title>{escaped_title}</title>
54+
<link rel="icon" type="image/x-icon" href="{favicon}">
55+
<style>
56+
:root {{ color-scheme: light dark; }}
57+
body {{
58+
margin: 0;
59+
min-height: 100vh;
60+
display: grid;
61+
place-items: center;
62+
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
63+
BlinkMacSystemFont, "Segoe UI", sans-serif;
64+
background: radial-gradient(circle at top, #1e293b 0, #0f172a 42%, #020617 100%);
65+
color: #f8fafc;
66+
}}
67+
main {{
68+
width: min(440px, calc(100vw - 48px));
69+
padding: 40px 32px;
70+
border: 1px solid rgba(148, 163, 184, 0.25);
71+
border-radius: 28px;
72+
background: rgba(15, 23, 42, 0.82);
73+
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
74+
text-align: center;
75+
}}
76+
.logo {{ width: 82px; height: auto; margin-bottom: 22px; }}
77+
h1 {{ margin: 0 0 12px; font-size: 2rem; line-height: 1.15; }}
78+
p {{ margin: 0; color: #cbd5e1; font-size: 1.05rem; line-height: 1.6; }}
79+
</style>
80+
</head>
81+
<body>
82+
<main>
83+
<img class="logo" src="{logo}" alt="Pythinker logo">
84+
<h1>{escaped_heading}</h1>
85+
<p>{escaped_body}</p>
86+
</main>
87+
</body>
88+
</html>"""

src/pythinker_code/auth/openai.py

Lines changed: 10 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import base64
55
import binascii
66
import hashlib
7-
import html
87
import json
98
import secrets
109
import time
@@ -17,6 +16,7 @@
1716
from pydantic import SecretStr
1817

1918
from pythinker_code.auth import OPENAI_API_PLATFORM_ID, OPENAI_CHATGPT_PLATFORM_ID
19+
from pythinker_code.auth.browser_login_page import build_browser_login_result_html
2020
from pythinker_code.auth.oauth import (
2121
OAuthError,
2222
OAuthEvent,
@@ -228,72 +228,17 @@ def _build_authorize_url(
228228
return f"{authorize_url}?{query}"
229229

230230

231-
_PYTHINKER_CALLBACK_LOGO_SVG = """
232-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Pythinker">
233-
<rect width="64" height="64" rx="16" fill="#0f172a"/>
234-
<rect x="12" y="20" width="40" height="28" rx="10" fill="#f9f2f5"/>
235-
<path d="M20 48h24l5 10H15z" fill="#ee9983"/>
236-
<circle cx="25" cy="34" r="6" fill="#afe3f1" stroke="#213853" stroke-width="4"/>
237-
<circle cx="39" cy="34" r="6" fill="#afe3f1" stroke="#213853" stroke-width="4"/>
238-
<path d="M27 45h10" stroke="#213853" stroke-width="4" stroke-linecap="round"/>
239-
<path d="M32 20V9" stroke="#213853" stroke-width="4" stroke-linecap="round"/>
240-
<circle cx="32" cy="8" r="5" fill="#ee9983"/>
241-
</svg>
242-
""".strip()
243-
244-
245231
def _callback_html(*, ok: bool, message: str | None) -> str:
246-
title = "Pythinker logged in" if ok else "Pythinker login failed"
247-
heading = "You're logged in to Pythinker" if ok else "Pythinker login failed"
248-
body = "You can close this tab and return to Pythinker." if ok else message
249-
escaped_title = html.escape(title)
250-
escaped_heading = html.escape(heading)
251-
escaped_body = html.escape(body or "OpenAI login failed.")
252-
favicon = html.escape(
253-
"data:image/svg+xml," + _PYTHINKER_CALLBACK_LOGO_SVG.replace("#", "%23"),
254-
quote=True,
232+
return build_browser_login_result_html(
233+
ok=ok,
234+
success_title="Pythinker logged in",
235+
failure_title="Pythinker login failed",
236+
success_heading="You're logged in to Pythinker",
237+
failure_heading="Pythinker login failed",
238+
success_body="You can close this tab and return to Pythinker.",
239+
failure_body=message,
240+
fallback_failure_body="OpenAI login failed.",
255241
)
256-
return f"""<!doctype html>
257-
<html lang="en">
258-
<head>
259-
<meta charset="utf-8">
260-
<meta name="viewport" content="width=device-width, initial-scale=1">
261-
<title>{escaped_title}</title>
262-
<link rel="icon" href="{favicon}">
263-
<style>
264-
:root {{ color-scheme: light dark; }}
265-
body {{
266-
margin: 0;
267-
min-height: 100vh;
268-
display: grid;
269-
place-items: center;
270-
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
271-
BlinkMacSystemFont, "Segoe UI", sans-serif;
272-
background: radial-gradient(circle at top, #1e293b 0, #0f172a 42%, #020617 100%);
273-
color: #f8fafc;
274-
}}
275-
main {{
276-
width: min(440px, calc(100vw - 48px));
277-
padding: 40px 32px;
278-
border: 1px solid rgba(148, 163, 184, 0.25);
279-
border-radius: 28px;
280-
background: rgba(15, 23, 42, 0.82);
281-
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
282-
text-align: center;
283-
}}
284-
.logo {{ width: 88px; height: 88px; margin-bottom: 22px; }}
285-
h1 {{ margin: 0 0 12px; font-size: 2rem; line-height: 1.15; }}
286-
p {{ margin: 0; color: #cbd5e1; font-size: 1.05rem; line-height: 1.6; }}
287-
</style>
288-
</head>
289-
<body>
290-
<main>
291-
{_PYTHINKER_CALLBACK_LOGO_SVG.replace("<svg ", '<svg class="logo" ')}
292-
<h1>{escaped_heading}</h1>
293-
<p>{escaped_body}</p>
294-
</main>
295-
</body>
296-
</html>"""
297242

298243

299244
async def _handle_browser_callback(

src/pythinker_code/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ class TUIConfig(BaseModel):
323323
"Set false or export PYTHINKER_DISABLE_PROMPT_HISTORY=1 for sensitive sessions."
324324
),
325325
)
326+
turn_recaps: bool = Field(
327+
default=True,
328+
description="Show a compact recap line after completed interactive shell turns.",
329+
)
326330

327331

328332
class MCPConfig(BaseModel):

src/pythinker_code/hooks/engine.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import inspect
45
import re
56
import time
67
import uuid
@@ -16,13 +17,77 @@
1617
type OnTriggered = Callable[[str, str, int], None]
1718
"""(event, target, hook_count) -> None"""
1819

19-
type OnResolved = Callable[[str, str, str, str, int], None]
20-
"""(event, target, action, reason, duration_ms) -> None"""
20+
type OnResolved = Callable[..., None]
21+
"""(event, target, action, reason, duration_ms[, outputs]) -> None.
22+
23+
Intentionally variadic: ``_resolved_callback_accepts_outputs`` inspects each
24+
concrete callable at runtime and calls it with 5 or 6 positional args, so both
25+
legacy 5-arg subscribers and opt-in 6-arg subscribers are valid. A stricter
26+
Protocol/overload type was tried and rejected — it statically excludes one of
27+
the two arities the runtime deliberately supports (see tests/hooks)."""
2128

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

33+
_MAX_HOOK_OUTPUT_CHARS = 12_000
34+
35+
36+
def _truncate_hook_output(text: str) -> tuple[str, bool]:
37+
if len(text) <= _MAX_HOOK_OUTPUT_CHARS:
38+
return text, False
39+
return text[:_MAX_HOOK_OUTPUT_CHARS].rstrip() + "\n...[truncated]", True
40+
41+
42+
def _hook_outputs_for_wire(results: list[HookResult]) -> tuple[dict[str, Any], ...]:
43+
outputs: list[dict[str, Any]] = []
44+
for result in results:
45+
stdout, stdout_truncated = _truncate_hook_output(result.stdout)
46+
stderr, stderr_truncated = _truncate_hook_output(result.stderr)
47+
if not stdout and not stderr and not result.timed_out:
48+
continue
49+
outputs.append(
50+
{
51+
"stdout": stdout,
52+
"stderr": stderr,
53+
"exit_code": result.exit_code,
54+
"timed_out": result.timed_out,
55+
"truncated": stdout_truncated or stderr_truncated,
56+
}
57+
)
58+
return tuple(outputs)
59+
60+
61+
def _resolved_callback_accepts_outputs(callback: OnResolved) -> bool:
62+
try:
63+
signature = inspect.signature(callback)
64+
except (TypeError, ValueError):
65+
return False
66+
parameters = tuple(signature.parameters.values())
67+
if any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in parameters):
68+
return True
69+
positional_kinds = {
70+
inspect.Parameter.POSITIONAL_ONLY,
71+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
72+
}
73+
positional = [param for param in parameters if param.kind in positional_kinds]
74+
return len(positional) >= 6
75+
76+
77+
def _call_on_resolved(
78+
callback: OnResolved,
79+
event: str,
80+
target: str,
81+
action: str,
82+
reason: str,
83+
duration_ms: int,
84+
outputs: tuple[dict[str, Any], ...],
85+
) -> None:
86+
if _resolved_callback_accepts_outputs(callback):
87+
callback(event, target, action, reason, duration_ms, outputs)
88+
else:
89+
callback(event, target, action, reason, duration_ms)
90+
2691

2792
@dataclass
2893
class WireHookSubscription:
@@ -332,7 +397,15 @@ async def _execute_hooks(
332397
# --- HookResolved ---
333398
if self._on_resolved:
334399
try:
335-
self._on_resolved(event, matcher_value, action, reason, duration_ms)
400+
_call_on_resolved(
401+
self._on_resolved,
402+
event,
403+
matcher_value,
404+
action,
405+
reason,
406+
duration_ms,
407+
_hook_outputs_for_wire(results),
408+
)
336409
except Exception as e:
337410
from pythinker_code.telemetry.errors import report_handled_error
338411

0 commit comments

Comments
 (0)