diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e0aa09..e8b4317d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ GitHub Releases page; `0.8.0` is the new starting line. - **Release promotion no longer stalls when the Homebrew tap is broken.** The `promote-release` workflow now gates only on platform assets and PyPI; a lagging or broken Homebrew tap emits a warning annotation and step summary note but no longer blocks the GitHub Release from reaching Latest. - **Calmer, theme-aligned TUI rendering.** Transcript, recap, and tool-header output now use theme-standardized activity colors instead of hardcoded values, and Markdown tables render as a bordered grid (wide tables no longer collapse into a stacked-record list). -- **Auto-mode tool approval fails closed when unattended.** In auto/non-interactive runs, an action that still needs approval under the active safe-mode/trust policy is denied with guidance instead of waiting indefinitely for an absent user, and outside-workspace writes are never auto-approved. A new `auto_deliberate_destructive_actions` setting can additionally bounce destructive auto-approved actions once for deliberation before they run. +- **Auto-mode tool approval fails closed when unattended.** In auto/non-interactive runs, an action that still needs approval under the active safe-mode/trust policy is denied with guidance instead of waiting indefinitely for an absent user, and outside-workspace writes are never auto-approved. A destructive auto-approved action is now bounced once for deliberation whenever no user is present (regardless of config), so the obvious `--yolo --auto` combination is no longer more dangerous than the `autonomous_coding` profile; the `auto_deliberate_destructive_actions` setting extends that backstop to interactive `--yolo` sessions, where a user is present but approvals are skipped. +- **Yolo + auto mode hardened against silent over-reach.** Entering plan mode now requires confirmation in an interactive `--yolo` session (matching exit), so the plan-review checkpoint is preserved when a user is present. A `--yolo` run no longer clears or persists the workspace's safe-mode/trust state. A new `--no-yolo` flag forces yolo off for a run — overriding the `--yolo` flag, the `default_yolo` config, and any resumed session state. Resuming a session that restores yolo and/or auto now surfaces a startup warning so it is never silent. - **`pythinker review` validates finding evidence.** Reviewflow assembles prompts from a shared security-knowledge manifest and validates findings, handling invalid ones without failing the whole review. ## 0.30.0 (2026-06-02) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index c148cddc..3a9f61fe 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,12 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Release promotion no longer stalls when the Homebrew tap is broken.** The `promote-release` workflow now gates only on platform assets and PyPI; a lagging or broken Homebrew tap emits a warning annotation and step summary note but no longer blocks the GitHub Release from reaching Latest. +- **Calmer, theme-aligned TUI rendering.** Transcript, recap, and tool-header output now use theme-standardized activity colors instead of hardcoded values, and Markdown tables render as a bordered grid (wide tables no longer collapse into a stacked-record list). +- **Auto-mode tool approval fails closed when unattended.** In auto/non-interactive runs, an action that still needs approval under the active safe-mode/trust policy is denied with guidance instead of waiting indefinitely for an absent user, and outside-workspace writes are never auto-approved. A destructive auto-approved action is now bounced once for deliberation whenever no user is present (regardless of config), so the obvious `--yolo --auto` combination is no longer more dangerous than the `autonomous_coding` profile; the `auto_deliberate_destructive_actions` setting extends that backstop to interactive `--yolo` sessions, where a user is present but approvals are skipped. +- **Yolo + auto mode hardened against silent over-reach.** Entering plan mode now requires confirmation in an interactive `--yolo` session (matching exit), so the plan-review checkpoint is preserved when a user is present. A `--yolo` run no longer clears or persists the workspace's safe-mode/trust state. A new `--no-yolo` flag forces yolo off for a run — overriding the `--yolo` flag, the `default_yolo` config, and any resumed session state. Resuming a session that restores yolo and/or auto now surfaces a startup warning so it is never silent. +- **`pythinker review` validates finding evidence.** Reviewflow assembles prompts from a shared security-knowledge manifest and validates findings, handling invalid ones without failing the whole review. + ## 0.30.0 (2026-06-02) ### What changed in this release diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec index e1a1b7ef..de05b1f6 100644 --- a/packages/linux-installer/pythinker.spec +++ b/packages/linux-installer/pythinker.spec @@ -39,6 +39,14 @@ for pkg in ( except Exception: pass +# Pygments loads a style module dynamically when config.tui.code_theme names a +# stock style (e.g. monokai); collect_submodules("rich") does not pull these in, +# so without this the frozen binary raises ClassNotFound on opted-in code themes. +try: + hiddenimports.extend(collect_submodules("pygments.styles")) +except Exception: + pass + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec index 76b4744f..9e6a45c2 100644 --- a/packages/windows-installer/pythinker.spec +++ b/packages/windows-installer/pythinker.spec @@ -38,6 +38,14 @@ for pkg in ( except Exception: pass +# Pygments loads a style module dynamically when config.tui.code_theme names a +# stock style (e.g. monokai); collect_submodules("rich") does not pull these in, +# so without this the frozen binary raises ClassNotFound on opted-in code themes. +try: + hiddenimports.extend(collect_submodules("pygments.styles")) +except Exception: + pass + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/src/pythinker_code/app.py b/src/pythinker_code/app.py index 2949f6ce..938cbb79 100644 --- a/src/pythinker_code/app.py +++ b/src/pythinker_code/app.py @@ -63,6 +63,23 @@ def _safe_git_branch(cwd: str | Path | HostPath) -> str | None: return branch or None +def _resumed_unsupervised_notice(*, resumed: bool, yolo: bool, auto: bool) -> str | None: + """Welcome-banner warning when a resumed session is running unsupervised. + + Resuming restores ``yolo``/``auto`` from persisted state, so a session can come back + unattended and, under YOLO, auto-approving actions with no prompt. Surface that + prominently at startup (it also fires when the modes were passed explicitly on the + resume command — acceptable over-notification). ``None`` when not a resume or no + unsupervised mode is active. + """ + if not resumed or not (yolo or auto): + return None + modes = " + ".join(name for name, active in (("YOLO", yolo), ("auto", auto)) if active) + if yolo: + return f"{modes} active — actions auto-approved; toggle with /yolo /auto" + return "auto active — interactive approvals still required; toggle with /auto" + + def _patch_session_id(record: dict[str, Any]) -> None: """Inject the current session ID (from ContextVar) into log records.""" try: @@ -153,6 +170,7 @@ async def create( thinking_effort: ThinkingEffort | None = None, # Run mode yolo: bool = False, + no_yolo: bool = False, auto: bool = False, runtime_auto: bool = False, plan_mode: bool = False, @@ -183,6 +201,8 @@ async def create( Defaults to None. yolo (bool, optional): Dangerously skip permission approvals. The user is still reachable via ``AskUserQuestion``. Defaults to False. + no_yolo (bool, optional): Force yolo OFF for this run, overriding the ``yolo`` + flag, config ``default_yolo``, and persisted session state. Defaults to False. auto (bool, optional): Invocation-level auto mode (no user is present to answer questions or approve actions). Implies auto-approve. Defaults to False. runtime_auto (bool, optional): Internal invocation-only auto-mode overlay, used by @@ -307,6 +327,7 @@ async def create( yolo, auto=auto, runtime_auto=runtime_auto, + no_yolo=no_yolo, skills_dirs=skills_dirs, scratchpad_section=scratchpad_section, ) @@ -783,6 +804,14 @@ async def run_shell( if branch_name: welcome_info.append(WelcomeInfoItem(name="Branch", value=branch_name)) welcome_info.append(WelcomeInfoItem(name="Session", value=self._runtime.session.id)) + if notice := _resumed_unsupervised_notice( + resumed=self._runtime.resumed, + yolo=self._runtime.approval.is_yolo(), + auto=self._runtime.approval.is_auto(), + ): + welcome_info.append( + WelcomeInfoItem(name="Mode", value=notice, level=WelcomeInfoItem.Level.WARN) + ) try: auto_save_path = str( shorten_home(HostPath.unsafe_from_local_path(self._runtime.session.context_file)) diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index c6e3585b..cfd137bf 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -378,6 +378,16 @@ def pythinker( help="Automatically approve all actions. Default: no.", ), ] = False, + no_yolo: Annotated[ + bool, + typer.Option( + "--no-yolo", + help=( + "Force yolo OFF for this run, overriding --yolo, config default_yolo, and " + "any persisted/resumed yolo state. Default: no." + ), + ), + ] = False, plan: Annotated[ bool, typer.Option( @@ -844,6 +854,7 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple thinking=thinking, thinking_effort=normalized_thinking_effort, yolo=yolo, + no_yolo=no_yolo, auto=auto, runtime_auto=ui == "print", plan_mode=plan, diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index d3adb4eb..4c2d5add 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -328,6 +328,39 @@ class TUIConfig(BaseModel): default=True, description="Show a compact recap line after completed interactive shell turns.", ) + code_theme: str = Field( + default="pythinker-ansi", + description=( + "Syntax-highlighting theme for assistant code blocks. Default " + "'pythinker-ansi' keeps the terminal-adaptive, transparent look. " + "Set to any Pygments style name (e.g. 'monokai', 'material', " + "'dracula', 'one-dark') to render code fences with that style on a " + "solid dark background block." + ), + ) + smooth_streaming: bool = Field( + default=True, + description=( + "Pace streamed assistant text so it reveals smoothly instead of " + "landing in bursty delta-sized clumps. Keeps up with the model " + "(bounded catch-up). Set false to reveal each delta immediately." + ), + ) + + @field_validator("code_theme") + @classmethod + def _validate_code_theme(cls, value: str) -> str: + from pythinker_code.utils.rich.syntax import available_code_themes + + allowed = available_code_themes() + if value in allowed: + return value + if value.lower() in allowed: + return value.lower() + raise ValueError( + f"Unknown code_theme {value!r}. Choose 'pythinker-ansi' or a Pygments " + f"style name. Available: {', '.join(allowed)}" + ) class MCPConfig(BaseModel): diff --git a/src/pythinker_code/soul/agent.py b/src/pythinker_code/soul/agent.py index f1ff6399..b9df91cb 100644 --- a/src/pythinker_code/soul/agent.py +++ b/src/pythinker_code/soul/agent.py @@ -218,6 +218,7 @@ async def create( yolo: bool, auto: bool = False, runtime_auto: bool = False, + no_yolo: bool = False, skills_dirs: list[HostPath] | None = None, scratchpad_section: str | None = None, ) -> Runtime: @@ -278,18 +279,26 @@ async def create( parts.append(f"### `{d}`\n\n```\n{dir_ls}\n```") additional_dirs_info = "\n\n".join(parts) - # Merge invocation flags with persisted session state. - effective_yolo = yolo or session.state.approval.yolo - # An explicit --yolo invocation is already a deliberate trust decision for - # this run, so it must not deadlock non-interactive/e2e flows behind safe mode. - effective_safe_mode = False if yolo else session.state.trust.safe_mode + # Merge invocation flags with persisted session state. ``--no-yolo`` is an explicit + # force-off that beats the flag, config ``default_yolo``, and persisted state. + original_persisted_yolo = session.state.approval.yolo + effective_yolo = (yolo or original_persisted_yolo) and not no_yolo + # Do NOT force safe_mode off under yolo: yolo already bypasses safe mode in the + # decision path (is_auto_approve / _unattended_denial_feedback short-circuit on + # yolo before reading safe_mode), so there is no deadlock to avoid — and forcing it + # False here used to get persisted back to trust state, silently downgrading the + # workspace's trust posture. + effective_safe_mode = session.state.trust.safe_mode if auto and not session.state.approval.auto: session.state.approval.auto = True session.save_state() saved_actions = set(session.state.approval.auto_approve_actions) def _on_approval_change() -> None: - session.state.approval.yolo = approval_state.yolo + if not no_yolo: + session.state.approval.yolo = approval_state.yolo + else: + session.state.approval.yolo = original_persisted_yolo session.state.approval.auto = approval_state.auto session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions) session.state.trust.safe_mode = approval_state.safe_mode diff --git a/src/pythinker_code/soul/approval.py b/src/pythinker_code/soul/approval.py index bd142dcb..47831c13 100644 --- a/src/pythinker_code/soul/approval.py +++ b/src/pythinker_code/soul/approval.py @@ -289,9 +289,11 @@ def _deliberation_fingerprint( def deliberation_gate(self, tool_call: ToolCall) -> str | None: """Reason a destructive auto-approved action must deliberate once, else ``None``. - Fires only when ``auto_deliberate`` is on, the action would otherwise be - auto-approved (auto *or* yolo — so it gates ahead of the yolo bypass), and the - tool call is destructive per the tool-agnostic classifier in ``permission`` + Fires when the action would otherwise be auto-approved (auto *or* yolo — so it + gates ahead of the yolo bypass), it is destructive, and either no user is present + (``is_auto`` — no human to veto, so deliberation is mandatory) or ``auto_deliberate`` + is on (which extends deliberation to the interactive-yolo case). Destructiveness is + classified by the tool-agnostic classifier in ``permission`` (today only ``Shell``; other destructive tools register their classifier there). One-shot, scoped to (execution context, generation): the first sighting and any same-generation duplicate are bounced; only a re-issue in a later generation of the @@ -299,7 +301,12 @@ def deliberation_gate(self, tool_call: ToolCall) -> str | None: permanently whitelisted, while two identical calls in one model response both deliberate and a subagent cannot consume the main agent's one-shot. """ - if not self._state.auto_deliberate: + # The destructive backstop must hold whenever an irreversible action would be + # auto-approved with NO user present (``is_auto``): there is no human to veto it, + # so the model must deliberate once first. The ``auto_deliberate`` config flag + # only EXTENDS this to the interactive-yolo case (a user IS present but approvals + # are skipped), where the human would otherwise see the action at approval time. + if not (self._state.auto_deliberate or self.is_auto()): return None if not self.is_auto_approve(): return None diff --git a/src/pythinker_code/soul/dynamic_injections/auto_mode.py b/src/pythinker_code/soul/dynamic_injections/auto_mode.py index 17e10471..e11dfeec 100644 --- a/src/pythinker_code/soul/dynamic_injections/auto_mode.py +++ b/src/pythinker_code/soul/dynamic_injections/auto_mode.py @@ -12,23 +12,6 @@ _AUTO_INJECTION_TYPE = "auto_mode" -_AUTO_PROMPT = ( - "You are running in auto mode. No user is present to answer questions or " - "approve actions.\n" - "- Do NOT call AskUserQuestion — it will be auto-dismissed with no answer, " - "wasting a turn. Make your best judgment and proceed.\n" - "- Tool calls are auto-approved only when the current trust/safe-mode policy " - "allows. If approval is unavailable, the tool fails closed instead of " - "waiting forever; choose a safe alternative or explain the required explicit " - "trust/yolo step.\n" - "- Outside-workspace file writes are not auto-approved by auto mode.\n" - "- You CAN use EnterPlanMode / ExitPlanMode normally when available. Planning " - "still helps you think before acting; use it for non-trivial tasks, then " - "exit and execute.\n" - "- Finish the user's request end-to-end in this run. Do not defer decisions " - "to a human." -) - _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE = ( "You are running in auto mode. No user is present to answer questions or " "approve actions.\n" @@ -93,20 +76,15 @@ async def get_injections( if self._injected: return [] self._injected = True - # Under the auto_deliberate policy AskUserQuestion self-decides (advisor- - # assisted) instead of being dismissed, so invite it at consequential - # forks. Destructive deliberation can also be enabled independently while - # AskUserQuestion remains auto-dismissed. - ask_deliberate = soul.runtime.config.ask_user_question_policy == "auto_deliberate" - destructive_deliberate = ( - soul.runtime.config.auto_deliberate_destructive_actions or ask_deliberate - ) - if ask_deliberate: + # No user is present, so a destructive auto-approved action is always bounced once + # for deliberation (see Approval.deliberation_gate) — surface that guidance in + # every auto prompt. Under the auto_deliberate policy AskUserQuestion additionally + # self-decides (advisor-assisted) at consequential forks instead of being + # dismissed, so invite it there. + if soul.runtime.config.ask_user_question_policy == "auto_deliberate": content = _AUTO_PROMPT_DELIBERATE - elif destructive_deliberate: - content = _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE else: - content = _AUTO_PROMPT + content = _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE return [DynamicInjection(type=_AUTO_INJECTION_TYPE, content=content)] async def on_context_compacted(self) -> None: diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 33cb49df..4c819385 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -541,7 +541,10 @@ def path_getter() -> Path | None: self.toggle_plan_mode, path_getter, checker, - self._approval.is_auto_approve, + # Match ExitPlanMode: gate on user presence (is_auto), not is_auto_approve. + # Yolo skips approvals but the user is still present, so an interactive + # yolo session should not silently slip into plan mode without confirming. + self._approval.is_auto, ) # AskUserQuestion — bind auto-mode checker for auto-dismiss. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 0f1d41d0..4c83cfe6 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -588,14 +588,18 @@ async def run(self, command: str | None = None) -> bool: # Initialize theme + TUI style from config if isinstance(self.soul, PythinkerSoul): from pythinker_code.extensions import run_pending_extensions + from pythinker_code.ui.shell.visualize._blocks import set_smooth_streaming from pythinker_code.ui.theme import set_active_theme from pythinker_code.ui.tui_config import ( is_card_style, set_active_tui_style, ) + from pythinker_code.utils.rich.syntax import set_active_code_theme set_active_theme(self.soul.runtime.config.theme) set_active_tui_style(self.soul.runtime.config.tui.style) + set_active_code_theme(self.soul.runtime.config.tui.code_theme) + set_smooth_streaming(self.soul.runtime.config.tui.smooth_streaming) if is_card_style(): from pythinker_code.ui.shell.tool_renderers import ( register_builtin_renderers, @@ -1895,7 +1899,7 @@ def _cancel_background_tasks(self) -> None: class WelcomeInfoItem: class Level(Enum): INFO = "grey50" - WARN = "yellow" + WARN = _LOGO_CORAL # muted coral, matching the robot's antenna accent ERROR = "red" name: str @@ -2032,7 +2036,7 @@ def _print_welcome_info( f"[{_t.muted}]Review · Secure · Diagnose · Build with confidence.[/]" ) help_text = Text.from_markup(f"[{_t.muted}]Type /help for commands.[/]") - help_text.highlight_regex(r"/help\b", f"bold {_t.warning}") + help_text.highlight_regex(r"/help\b", f"bold {_LOGO_CORAL}") rows: list[RenderableType] = [] if content_width >= 68: @@ -2078,7 +2082,7 @@ def _print_welcome_info( for item in tips: for index, line in enumerate(_welcome_tip_lines(item.value, tip_width)): tip_text = Text(line, style=item.level.value, no_wrap=True) - tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold") + tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", f"bold {_LOGO_CORAL}") tips_table.add_row(" • " if index == 0 else " ", tip_text) rows.append(tips_table) diff --git a/src/pythinker_code/ui/shell/components/markdown.py b/src/pythinker_code/ui/shell/components/markdown.py index d6da235f..5a30bdbd 100644 --- a/src/pythinker_code/ui/shell/components/markdown.py +++ b/src/pythinker_code/ui/shell/components/markdown.py @@ -8,10 +8,11 @@ 2. Inline elements (headings, strong, emphasis, links, inline code, block quotes) resolve against ``pythinker_code.ui.theme.get_markdown_colors`` so dark/light themes share the same renderer. -3. A small streaming helper, :class:`PythinkerMarkdownStream`, buffers - incoming deltas and flushes when a safe boundary appears — either a - blank line *or* a sentence-final character outside of any open fence, - so long paragraphs no longer stall the visible stream. +3. The public function :func:`markdown_commit_boundary` returns the safe + commit offset for streamed markdown; the production rendering path in + ``_ContentBlock._flush_committed`` calls it directly. The lower-level + :class:`PythinkerMarkdownStream` class is an internal/testing helper and + is not part of the public API. """ from __future__ import annotations @@ -36,7 +37,6 @@ from pythinker_code.ui.shell.spacing import CODE_BLOCK_PADDING, blank_row from pythinker_code.ui.theme import ThemeName, get_markdown_colors from pythinker_code.utils.rich.markdown import CodeBlock, Markdown -from pythinker_code.utils.rich.syntax import PYTHINKER_ANSI_THEME_NAME _MARKDOWN_ICON_REPLACEMENTS: dict[str, str] = { "⏺": "•", @@ -85,7 +85,6 @@ __all__ = [ "PythinkerMarkdown", - "PythinkerMarkdownStream", "pythinker_markdown", ] @@ -222,9 +221,17 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR colors = get_markdown_colors() border_style = RichStyle(color=colors.code_block_border, bold=True) - panel_style = ( - RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle() - ) + # ``self.theme`` is a str only for an opted-in stock Pygments style; the + # default ANSI theme resolves to a ``SyntaxTheme`` instance. For a stock + # style, paint the panel with the style's own background so the code and + # its padding share one uniform dark block (Aider-style); otherwise keep + # the calm ``code_block_bg`` tint. + if isinstance(self.theme, str): + panel_style = Syntax.get_theme(self.theme).get_background_style() + else: + panel_style = ( + RichStyle(bgcolor=colors.code_block_bg) if colors.code_block_bg else RichStyle() + ) syntax = Syntax( code_text, @@ -561,10 +568,11 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR yield from super().__rich_console__(console, options) -def pythinker_markdown( - text: str, *, code_theme: str = PYTHINKER_ANSI_THEME_NAME -) -> PythinkerMarkdown: - """Build a :class:`PythinkerMarkdown` with the palette pre-wired.""" +def pythinker_markdown(text: str, *, code_theme: str | None = None) -> PythinkerMarkdown: + """Build a :class:`PythinkerMarkdown` with the palette pre-wired. + + ``code_theme=None`` defers to the active code theme (``config.tui.code_theme``). + """ return PythinkerMarkdown(text, code_theme=code_theme) @@ -646,7 +654,12 @@ def _find_stream_safe_boundary(text: str) -> int | None: @dataclass(slots=True) class PythinkerMarkdownStream: - """Buffer streamed markdown deltas and yield safe-to-render slices.""" + """Buffer streamed markdown deltas and yield safe-to-render slices. + + Internal testing fixture for markdown boundary behavior. The production + rendering path in ``_ContentBlock._flush_committed`` calls + :func:`markdown_commit_boundary` directly. + """ pending: str = field(default="") diff --git a/src/pythinker_code/ui/shell/echo.py b/src/pythinker_code/ui/shell/echo.py index c6e10093..a862dc2a 100644 --- a/src/pythinker_code/ui/shell/echo.py +++ b/src/pythinker_code/ui/shell/echo.py @@ -3,11 +3,13 @@ from pythinker_core.message import Message from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult from rich.measure import Measurement +from rich.padding import Padding from rich.text import Text from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown from pythinker_code.ui.shell.prompt import PROMPT_SYMBOL_AGENT_INPUT from pythinker_code.ui.shell.spacing import BLANK_ROW +from pythinker_code.ui.theme import tui_rich_style from pythinker_code.utils.message import message_stringify from pythinker_code.utils.rich.columns import BulletColumns @@ -18,15 +20,21 @@ class UserEcho: def __init__(self, text: str) -> None: self._text = text self.plain = f"{PROMPT_SYMBOL_AGENT_INPUT} {text}" - self._body = BulletColumns( + body = BulletColumns( PythinkerMarkdown(text), bullet=Text(PROMPT_SYMBOL_AGENT_INPUT), padding=1 ) + # Echo the submitted prompt inside a full-width tinted band using the + # shared ``user_message_bg`` palette token, so the user's message reads + # as a distinct block in the transcript. ``Padding`` expands to the full + # width and ``tui_rich_style`` yields an empty style on no-color + # terminals, so this degrades cleanly. + self._block = Padding(body, (0, 1, 0, 1), style=tui_rich_style("user_message_bg")) def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: - return Measurement.get(console, options, self._body) + return Measurement.get(console, options, self._block) def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - yield from console.render(Group(BLANK_ROW, self._body), options) + yield from console.render(Group(BLANK_ROW, self._block), options) def render_user_echo(message: Message) -> RenderableType: diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index 5c6f02d0..cd8c7796 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -33,9 +33,10 @@ def _activity_color_tokens() -> tuple[str, str, str, str]: """Return theme-standardized activity colors. - The active verb uses a premium champagne/platinum ramp in dark mode and a - contrast-safe bronze/ink ramp in light mode. Keep these in theme tokens so - Rich and prompt_toolkit renderers stay visually aligned. + The active verb uses a coral ramp — muted coral resting (matching the + robot's antenna accent), sweeping to a soft light-coral spark in dark mode, + and a contrast-safe deep-coral ramp in light mode. Keep these in theme + tokens so Rich and prompt_toolkit renderers stay visually aligned. """ tokens = get_tui_tokens() return ( @@ -55,10 +56,12 @@ def verb_spinner_style() -> Style: # Backwards-compatible dark-theme constants used by tests and older callers. -_SHIMMER_BASE = "#C8B176" -_SHIMMER_MID = "#E1CC94" -_SHIMMER_HIGHLIGHT = "#EEF2F7" -_SHIMMER_INTERVAL_S = 0.22 +# These MUST stay in lockstep with the dark `activity_verb*` theme tokens so the +# shimmer renders the same warm ember ramp everywhere. +_SHIMMER_BASE = "#EE9983" +_SHIMMER_MID = "#F4B5A5" +_SHIMMER_HIGHLIGHT = "#FBD9CE" +_SHIMMER_INTERVAL_S = 0.15 _SPINNER_SILVER = "#B8C0CC" diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 6c5faa9d..3758902f 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -1831,6 +1831,13 @@ def _tip(binding: str, fallback: str, description: str) -> str: _TIP_SEPARATOR = " | " +# Cap prompt redraws at ~30 fps. Smooth streaming calls ``invalidate()`` on a +# fast cadence; without this, prompt_toolkit redraws on *every* invalidate, +# which (per its own docs) "could cause a lot of terminal output, which some +# terminals are not able to process" — the classic streaming flicker/lag. With +# it, rapid invalidations coalesce into at most one redraw per interval. +_MIN_REDRAW_INTERVAL_S = 1 / 30 + class CustomPromptSession: def __init__( @@ -2261,6 +2268,11 @@ def _(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, style=get_prompt_style(), ) + # Throttle redraws so the fast streaming-reveal cadence can't overwhelm + # slower terminals (best practice for "invalidate is called a lot"). + # prompt_toolkit's renderer is already differential (only emits changed + # cells), so this caps frame rate without forcing full repaints. + self._session.app.min_redraw_interval = _MIN_REDRAW_INTERVAL_S self._session.default_buffer.read_only = Condition( lambda: ( (delegate := self._active_prompt_delegate()) is not None diff --git a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py index d740d692..54ce11c0 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/ask_user.py +++ b/src/pythinker_code/ui/shell/tool_renderers/ask_user.py @@ -82,7 +82,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: children.append(blank_row()) question_text = as_str(q.get("question")) or "" if question_text: - children.append(fg("accent", f"? {question_text}")) + children.append(fg("accent", f"● {question_text}")) opts = q.get("options") if isinstance(opts, list): opts_list = cast("list[Any]", opts) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 61382e85..874948bd 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -73,6 +73,30 @@ _ELLIPSIS = "..." _THINKING_PREVIEW_LINES = 6 _COMPOSING_PREVIEW_LINES = 12 + +# Smooth-streaming reveal pacing (composing text only). Deltas arrive bursty; +# instead of revealing each whole chunk at once, a paced reveal cursor advances +# a little per refresh tick so text flows smoothly. "Keep up" pacing: the step +# scales with the backlog so a fast model never lags noticeably behind. +_STREAM_REVEAL_MIN_CELLS = 2 +_STREAM_REVEAL_CATCHUP_TICKS = 2 +_TOKEN_RATE_WINDOW_S = 1.5 +_TOKEN_RATE_MIN_SAMPLES = 3 + +_smooth_streaming_enabled = False + + +def set_smooth_streaming(enabled: bool) -> None: + """Set whether interactive assistant text reveal is paced for smooth streaming.""" + global _smooth_streaming_enabled + _smooth_streaming_enabled = enabled + + +def smooth_streaming_enabled() -> bool: + """Return whether paced streaming is enabled (set at shell startup from config).""" + return _smooth_streaming_enabled + + MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4 _MAX_RUNNING_ROWS = 2 _MAX_SUB_OUTPUT_CHARS = 200 @@ -160,11 +184,6 @@ def _find_committed_boundary(text: str) -> int | None: return markdown_commit_boundary(text) -def _starts_with_report_fence(text: str) -> bool: - stripped = text.lstrip().casefold() - return stripped.startswith(("```report", "~~~report")) - - def _tail_lines(text: str, n: int) -> str: """Extract the last *n* lines from *text* via reverse scanning (O(n)).""" pos = len(text) @@ -175,6 +194,20 @@ def _tail_lines(text: str, n: int) -> str: return text[pos + 1 :] +def _advance_by_display_cells(text: str, start: int, cell_budget: int) -> int: + """Return a character offset advanced by roughly ``cell_budget`` terminal cells.""" + from rich.cells import cell_len + + if cell_budget <= 0: + return start + width = 0 + for index in range(start, len(text)): + width += cell_len(text[index]) + if width >= cell_budget: + return index + 1 + return len(text) + + class _ContentBlock: """Streaming content block with incremental markdown commitment. @@ -196,26 +229,82 @@ class _ContentBlock: to history when the block ends. """ - def __init__(self, is_think: bool, *, show_thinking_stream: bool = False): + def __init__(self, is_think: bool, *, show_thinking_stream: bool = False, paced: bool = False): self.is_think = is_think self._show_thinking_stream = show_thinking_stream + # When paced, composing text is revealed gradually by ``reveal_tick`` + # instead of all at once on each delta, for smooth streaming. + self._paced = paced and not is_think self.raw_text = "" # Accumulated float estimate — avoids per-chunk int truncation. self._token_count: float = 0.0 self._start_time = time.monotonic() # Incremental commitment state (composing only). self._committed_len = 0 + # Characters of raw_text revealed for display/commit. Unpaced blocks keep + # this equal to len(raw_text); paced blocks advance it via reveal_tick(). + self._revealed_len = 0 self._has_printed_bullet = False + # Sliding window for smooth token-rate display: stores (timestamp, cumulative_tokens) + # pairs to compute rate over the last ~1.5s. Float cumulative_tokens avoids + # per-sample truncation. + self._token_samples: deque[tuple[float, float]] = deque() # -- Public API ---------------------------------------------------------- def append(self, content: str) -> None: self.raw_text += content self._token_count += _estimate_tokens(content) + if self._paced: + # Reveal is paced by reveal_tick() for smooth streaming; just buffer + # the raw text here. Commit happens as text is revealed. + return + # Unpaced (and all thinking blocks): reveal immediately (legacy behavior). + self._revealed_len = len(self.raw_text) # Block boundaries require newlines; skip parse for mid-line chunks. if not self.is_think and "\n" in content: self._flush_committed() + def reveal_tick(self) -> bool: + """Advance the paced reveal cursor toward the buffered text. + + Reveals a slice sized to the backlog (keep-up pacing) so the display + stays close to a fast model while still animating smoothly, committing + any completed markdown blocks as they are revealed. Returns ``True`` when + new text was revealed (the caller should refresh). No-op for unpaced or + thinking blocks. + """ + if not self._paced: + return False + from rich.cells import cell_len + + hidden = self.raw_text[self._revealed_len :] + backlog_cells = cell_len(hidden) + if backlog_cells <= 0: + return False + step_cells = max( + _STREAM_REVEAL_MIN_CELLS, + -(-backlog_cells // _STREAM_REVEAL_CATCHUP_TICKS), + ) + self._revealed_len = _advance_by_display_cells( + self.raw_text, + self._revealed_len, + step_cells, + ) + self._flush_committed() + return True + + def reveal_all(self) -> bool: + """Reveal all buffered text immediately (block finalize / fast-drain). + + Returns ``True`` if the reveal cursor moved. Commitment of the remaining + text is left to the finalize path (``compose_final``), matching the + unpaced behavior so no block is committed twice. + """ + changed = self._revealed_len < len(self.raw_text) + self._revealed_len = len(self.raw_text) + return changed + def compose(self) -> RenderableType: """Render the transient Live area content. @@ -255,7 +344,10 @@ def compose_final(self) -> RenderableType: if not remaining: return Text("") rendered = self._wrap_bullet(render_agent_body(remaining)) - if self._has_printed_bullet and _starts_with_report_fence(remaining): + if self._has_printed_bullet: + # Re-create the one-row gap a single markdown pass puts between + # blocks: earlier slices already committed, so the tail needs a + # seam to avoid cramming against the previous block. return Group(BLANK_ROW, rendered) return rendered @@ -270,7 +362,7 @@ def has_pending(self) -> bool: # -- Private ------------------------------------------------------------- def _pending_text(self) -> str: - return self.raw_text[self._committed_len :] + return self.raw_text[self._committed_len : self._revealed_len] def _wrap_bullet(self, renderable: RenderableType) -> BulletColumns: """First call gets the ``•`` bullet; subsequent calls get a space.""" @@ -305,26 +397,21 @@ def _flush_committed(self) -> None: if boundary is None: return committed_text = pending[:boundary] - if not self._has_printed_bullet: - # Leading blank row separates this step from the previous block. - console.print() - elif _starts_with_report_fence(committed_text): - # If prose streamed earlier and the next committed slice begins with - # a report fence, preserve the same one-row seam users get when the - # prose and report render together in a single markdown pass. - console.print() + # A blank seam precedes every committed slice: on the first commit it + # separates this step from the previous block; on later commits it + # re-creates the one-row gap a single markdown pass puts between blocks + # (committing each slice with its own console.print() drops it). + console.print() console.print(self._wrap_bullet(render_agent_body(committed_text))) self._committed_len += boundary def _activity_snapshot( self, label: str, *, label_style: Style | None = None ) -> ActivitySnapshot: - elapsed = time.monotonic() - self._start_time + now = time.monotonic() + elapsed = now - self._start_time tokens_int = int(self._token_count) - token_rate = None - if elapsed > 0.5 and tokens_int > 0: - rate = int(tokens_int / elapsed) - token_rate = rate if rate > 0 else None + token_rate = self._record_token_rate_sample(now) return ActivitySnapshot( label=label, elapsed_s=elapsed, @@ -335,13 +422,41 @@ def _activity_snapshot( spinner="shape", ) + def _record_token_rate_sample(self, now: float) -> int | None: + """Return a stable recent tokens/sec estimate, or None until enough data exists.""" + self._token_samples.append((now, self._token_count)) + while ( + len(self._token_samples) > 1 and now - self._token_samples[0][0] > _TOKEN_RATE_WINDOW_S + ): + self._token_samples.popleft() + if len(self._token_samples) < _TOKEN_RATE_MIN_SAMPLES: + return None + first_t, first_tokens = self._token_samples[0] + last_t, last_tokens = self._token_samples[-1] + elapsed = last_t - first_t + if elapsed <= 0: + return None + token_delta = last_tokens - first_tokens + if token_delta <= 0: + return None + rate = int(token_delta / elapsed) + return rate if rate > 0 else None + def _compose_composing(self) -> RenderableType: spinner = self._compose_spinner() pending = self._pending_text() if not pending: return spinner preview = self._build_preview(pending, max_lines=_COMPOSING_PREVIEW_LINES) - return Group(spinner, BLANK_ROW, self._wrap_preview_bullet(Markdown(preview))) + if self._paced: + # At the fast reveal cadence, re-parsing markdown every frame makes + # partial syntax (``**bol`` → ``**bold``, half-open ``` fences) + # flicker char-by-char. Render the uncommitted tail as plain text; + # completed blocks still commit to full markdown via render_agent_body. + body: RenderableType = Text(sanitize_ansi(preview)) + else: + body = Markdown(preview) + return Group(spinner, BLANK_ROW, self._wrap_preview_bullet(body)) def _compose_spinner(self) -> Text: return activity_status_line( diff --git a/src/pythinker_code/ui/shell/visualize/_interactive.py b/src/pythinker_code/ui/shell/visualize/_interactive.py index 0de734a9..5c000643 100644 --- a/src/pythinker_code/ui/shell/visualize/_interactive.py +++ b/src/pythinker_code/ui/shell/visualize/_interactive.py @@ -29,6 +29,7 @@ CustomPromptSession, UserInput, ) +from pythinker_code.ui.shell.visualize._blocks import smooth_streaming_enabled from pythinker_code.ui.shell.visualize._btw_panel import _BtwModalDelegate from pythinker_code.ui.shell.visualize._input_router import InputAction, classify_input from pythinker_code.ui.shell.visualize._live_view import _LiveView @@ -56,6 +57,9 @@ _STATUS_REFRESH_INTERVAL_S = 0.22 _STATUS_REFRESH_REDUCED_INTERVAL_S = 1.0 +# Fast tick while paced streamed text is actively revealing (~25 fps) so the +# reveal animates smoothly; falls back to the status cadence when idle. +_STREAM_REVEAL_INTERVAL_S = 0.04 class _PromptLiveView(_LiveView): @@ -88,6 +92,10 @@ def __init__( show_thinking_stream=show_thinking_stream, show_turn_recaps=show_turn_recaps, ) + # The interactive view owns the reveal tick (_status_refresh_loop), so it + # is the only view that paces streamed text. Disable pacing under reduced + # motion so motion-sensitive users get immediate reveal, not a typewriter. + self._stream_pacing = smooth_streaming_enabled() and not reduced_motion_enabled() self._prompt_session = prompt_session self._steer = steer self._btw_runner = btw_runner @@ -187,6 +195,15 @@ async def _status_refresh_loop(self) -> None: """ try: while True: + # Drain buffered paced text smoothly, even past TurnEnd, so the + # tail flows out instead of popping when the block finally + # commits. advance_stream_reveal() is a no-op unless a paced block + # has backlog, so reduced-motion / unpaced turns fall straight + # through to the calm status cadence below. + if self.advance_stream_reveal(): + self._prompt_session.invalidate() + await asyncio.sleep(_STREAM_REVEAL_INTERVAL_S) + continue interval = ( _STATUS_REFRESH_REDUCED_INTERVAL_S if reduced_motion_enabled() diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 8b4a4654..d6320485 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -201,6 +201,10 @@ def __init__( self._cancel_event = cancel_event self._show_thinking_stream = show_thinking_stream self._show_turn_recaps = show_turn_recaps + # Paced reveal of streamed composing text. Off by default; the + # interactive prompt view enables it (it owns the reveal tick), so the + # non-interactive Rich Live path stays byte-for-byte unchanged. + self._stream_pacing = False self._active_turn_depth = 0 self._turn_start_time: float | None = None @@ -380,6 +384,17 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: def refresh_soon(self) -> None: self._need_recompose = True + def advance_stream_reveal(self) -> bool: + """Advance paced reveal of the active composing block by one tick. + + Returns ``True`` when new text was revealed so the caller can refresh. + No-op unless a paced composing block is active. + """ + block = self._current_content_block + if block is None: + return False + return block.reveal_tick() + def _on_question_panel_state_changed(self) -> None: """Hook for subclasses to react when question panel visibility changes.""" return None @@ -644,7 +659,10 @@ def _todo_activity_line( ) ) else: - line.append(label_text, style=tui_rich_style("activity_label") + Style(bold=True)) + # The active-todo title uses the blue ``accent`` highlight (the same + # tone as other highlighted text) so the pinned line stands out from + # neutral body text. + line.append(label_text, style=tui_rich_style("accent") + Style(bold=True)) line.append(suffix, style=tui_rich_style("muted")) return line @@ -1117,6 +1135,10 @@ def discard_retry_attempt(self, retry: StepRetry) -> None: def flush_content(self) -> None: """Flush the current content block.""" if self._current_content_block is not None: + # Finalize must show everything: reveal any still-buffered paced text + # so the committed block is complete (no text stranded behind the + # reveal cursor). + self._current_content_block.reveal_all() if self._current_content_block.has_pending(): # One blank row before the block (matching tool cards) so steps # are separated — unless this block already streamed earlier @@ -1169,13 +1191,17 @@ def append_content(self, part: ContentPart) -> None: self._current_step_retry = None if self._current_content_block is None: self._current_content_block = _ContentBlock( - is_think, show_thinking_stream=self._show_thinking_stream + is_think, + show_thinking_stream=self._show_thinking_stream, + paced=self._stream_pacing, ) self.refresh_soon() elif self._current_content_block.is_think != is_think: self.flush_content() self._current_content_block = _ContentBlock( - is_think, show_thinking_stream=self._show_thinking_stream + is_think, + show_thinking_stream=self._show_thinking_stream, + paced=self._stream_pacing, ) self.refresh_soon() if text: diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index ef3f88d0..eebe23b1 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -484,9 +484,9 @@ class TuiTokens: text="", thinking_text="#C0C0C0", activity_label="#F4F4F5", - activity_verb="#C8B176", - activity_verb_mid="#E1CC94", - activity_verb_highlight="#EEF2F7", + activity_verb="#EE9983", + activity_verb_mid="#F4B5A5", + activity_verb_highlight="#FBD9CE", activity_spinner="#B8C0CC", selected_bg="#243C54", user_message_bg="#1B2738", @@ -521,9 +521,9 @@ class TuiTokens: text="#213853", thinking_text="#7A7A7A", activity_label="#213853", - activity_verb="#7A5C24", - activity_verb_mid="#8A6A2D", - activity_verb_highlight="#213853", + activity_verb="#C56B4F", + activity_verb_mid="#B0573C", + activity_verb_highlight="#8F3A26", activity_spinner="#6B7280", selected_bg="#E6F2F6", user_message_bg="#F0E4E4", diff --git a/src/pythinker_code/utils/pyinstaller.py b/src/pythinker_code/utils/pyinstaller.py index e9e1788f..7cfd525d 100644 --- a/src/pythinker_code/utils/pyinstaller.py +++ b/src/pythinker_code/utils/pyinstaller.py @@ -15,6 +15,11 @@ # `cli/__init__.py` resolves _lazy_group via `import_module(f"{__name__}._lazy_group")`, # which PyInstaller's static analysis can't follow. + ["pythinker_code.cli._lazy_group", "setproctitle"] + # Pygments resolves a style module dynamically (e.g. `import + # pygments.styles.monokai`) when config.tui.code_theme names a stock style, + # so static analysis misses it and the frozen binary raises ClassNotFound. + # Collect all style modules so any opted-in code_theme resolves. + + collect_submodules("pygments.styles") ) datas = ( collect_data_files( diff --git a/src/pythinker_code/utils/rich/markdown.py b/src/pythinker_code/utils/rich/markdown.py index c342bdc3..e804fab0 100644 --- a/src/pythinker_code/utils/rich/markdown.py +++ b/src/pythinker_code/utils/rich/markdown.py @@ -24,7 +24,11 @@ from rich.table import Table from rich.text import Text, TextType -from pythinker_code.utils.rich.syntax import PYTHINKER_ANSI_THEME_NAME, resolve_code_theme +from pythinker_code.utils.rich.syntax import ( + PYTHINKER_ANSI_THEME_NAME, + get_active_code_theme, + resolve_code_theme, +) LIST_INDENT_WIDTH = 2 @@ -655,8 +659,9 @@ class Markdown(JupyterMixin): Args: markup (str): A string containing markdown. - code_theme (str, optional): Pygments theme for code blocks. Defaults to "pythinker-ansi". - See https://pygments.org/styles/ for code themes. + code_theme (str, optional): Pygments theme for code blocks. Defaults to None, + which defers to the process-wide active code theme (``pythinker-ansi`` + unless overridden by config). See https://pygments.org/styles/ for code themes. justify (JustifyMethod, optional): Justify value for paragraphs. Defaults to None. style (Union[str, Style], optional): Optional style to apply to markdown. hyperlinks (bool, optional): Enable hyperlinks. Defaults to ``True``. @@ -690,22 +695,25 @@ class Markdown(JupyterMixin): def __init__( self, markup: str, - code_theme: str = PYTHINKER_ANSI_THEME_NAME, + code_theme: str | None = None, justify: JustifyMethod | None = None, style: str | Style = "none", hyperlinks: bool = True, inline_code_lexer: str | None = None, inline_code_theme: str | None = None, ) -> None: + # ``None`` defers to the process-wide active code theme (set at startup + # from ``config.tui.code_theme``); pass an explicit name to override. + resolved_name = code_theme if code_theme is not None else get_active_code_theme() parser = MarkdownIt().enable("strikethrough").enable("table") self.markup = markup self.parsed = parser.parse(markup) - self.code_theme = resolve_code_theme(code_theme) + self.code_theme = resolve_code_theme(resolved_name) self.justify: JustifyMethod | None = justify self.style = style self.hyperlinks = hyperlinks self.inline_code_lexer = inline_code_lexer - self.inline_code_theme = resolve_code_theme(inline_code_theme or code_theme) + self.inline_code_theme = resolve_code_theme(inline_code_theme or resolved_name) def _flatten_tokens(self, tokens: Iterable[Token]) -> Iterable[Token]: """Flattens the token stream.""" diff --git a/src/pythinker_code/utils/rich/syntax.py b/src/pythinker_code/utils/rich/syntax.py index 86a121e4..413d8c3b 100644 --- a/src/pythinker_code/utils/rich/syntax.py +++ b/src/pythinker_code/utils/rich/syntax.py @@ -84,10 +84,41 @@ def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme: return theme +def available_code_themes() -> list[str]: + """Accepted ``code_theme`` values: the ANSI sentinel plus every stock Pygments style. + + Imported lazily so the (modest) Pygments style enumeration cost is only paid + when a config value is validated, not on every ``syntax`` import. + """ + from pygments.styles import get_all_styles + + return [PYTHINKER_ANSI_THEME_NAME, *sorted(get_all_styles())] + + +# Process-wide default code-fence theme, resolved once at shell startup from +# ``config.tui.code_theme``. Mirrors ``ui.theme`` set_active_theme/get_active_theme +# so renderers pick up the configured theme without threading config through +# every call site. ``PYTHINKER_ANSI_THEME_NAME`` keeps today's transparent look. +_active_code_theme: str = PYTHINKER_ANSI_THEME_NAME + + +def set_active_code_theme(theme: str) -> None: + """Set the process-wide default code-fence theme (Pygments style name or ANSI sentinel).""" + global _active_code_theme + _active_code_theme = theme + + +def get_active_code_theme() -> str: + """Return the active code-fence theme name (defaults to the ANSI sentinel).""" + return _active_code_theme + + class PythinkerSyntax(Syntax): def __init__(self, code: str, lexer: str, **kwargs: Any) -> None: if "theme" not in kwargs or kwargs["theme"] is None: - kwargs["theme"] = PYTHINKER_ANSI_THEME + kwargs["theme"] = resolve_code_theme( + get_active_code_theme() or PYTHINKER_ANSI_THEME_NAME + ) super().__init__(code, lexer, **kwargs) diff --git a/tasks/yolo-auto-mode-analysis.md b/tasks/yolo-auto-mode-analysis.md new file mode 100644 index 00000000..e91ffe31 --- /dev/null +++ b/tasks/yolo-auto-mode-analysis.md @@ -0,0 +1,217 @@ +# YOLO + Auto Mode: Hypotheses, Behavior, and Test Plan + +**Question:** What happens when *YOLO mode* is combined with *auto mode* in pythinker, and what bugs/issues can appear? + +**Scope:** Analysis of `feat/auto-mode-tui-rendering`. Every claim cites `file:line`. + +> **Update (2026-06-02): B1, B2, B3 (a/b/c) fixed in this branch; B4 resolved as correct-as-designed.** See §7. + +--- + +## 0. The two flags (definitions) + +| Flag | Identifier | Meaning | Persisted? | +|---|---|---|---| +| **YOLO** | `ApprovalState.yolo` (`soul/approval.py:145`) | "Dangerously skip permission approvals." Explicit opt-in. | Yes — `session_state.py:16` | +| **Auto** | `ApprovalState.auto` / `runtime_auto` (`soul/approval.py`; `is_auto()` at `approval.py:244`) | "No user is present at the terminal." | `auto` yes (`session_state.py:17`); `runtime_auto` no (`--print` only) | + +Key compound: `is_auto_approve()` (`approval.py:223-234`): + +```python +if yolo: return True # YOLO overrides everything below +if safe_mode: return False # untrusted workspace blocks auto (but NOT yolo) +return is_auto() +``` + +--- + +## 1. TL;DR — what the combination actually does + +With **both** flags on, the agent is in the **most permissive state the system can reach**: every tool call is auto-approved with no human in the loop, the agent cannot pause to ask the user, and it can both *enter and exit plan mode by itself* — defeating the plan checkpoint. + +**The single most important finding:** the only destructive-action backstop, the *deliberation gate*, is **OFF by default** (`auto_deliberate_destructive_actions` defaults to `False`, `config.py:381-382`; the gate requires it at `approval.py:302`). It is turned **on** only by the purpose-built `autonomous_coding` profile (`config.py:492-493`). Therefore: + +> The **obvious** way to run unsupervised — typing `--yolo --auto` with a default config — is **strictly more dangerous** than the purpose-built `autonomous_coding` profile, because the manual path leaves the deliberation gate disabled. In that state `rm -rf /tmp/x`, `git reset --hard`, `git push --force` all auto-approve with **zero friction**. + +--- + +## 2. How you realistically end up here (activation paths) + +This is not a contrived combination: + +1. **`autonomous_coding` profile + `--auto`/`--print`** — the profile sets `default_yolo=True` (`config.py:487-488`) and `auto_deliberate=True` (`config.py:492-493`) and `ask_user_question_policy="never"` (`config.py:489-491`). Add auto/print and both flags are on. *This is the intended combination and it has the deliberation backstop.* +2. **Manual `--yolo --auto`** (default config) — both flags on, **deliberation gate off**. *The dangerous one.* +3. **Resume** — both `yolo` and `auto` persist to `state.json`; on resume `effective_yolo = yolo or session.state.approval.yolo` (`agent.py:282`) and `auto = session.state.approval.auto` (`agent.py:300`). A session toggled into `/yolo` + `/auto` once **silently resumes fully unsupervised** with no re-confirmation, and there is no CLI flag to force it *off*. +4. **`--print` + config `default_yolo`** — one-shot non-interactive run, fully unsupervised, no checkpoint, can't ask. + +--- + +## 3. Combined behavior, by action (default config, yolo+auto) + +| Action | Result | Why | +|---|---|---| +| WriteFile / StrReplaceFile (in workspace) | auto-approved | `is_auto_approve→True` (`approval.py:230`); file tools not in destructive registry | +| WriteFile **outside** workspace (`~/.bashrc`, `~/.ssh/`) | auto-approved | YOLO makes `_unattended_denial_feedback→None` (`approval.py:258`), bypassing the outside-workspace guard (`approval.py:260`) | +| `rm -rf`, `git reset --hard`, `git push --force` | **auto-approved, no bounce** (default) | deliberation gate needs `auto_deliberate=True` (`approval.py:302`), default False | +| same, under `autonomous_coding` | bounced **once**, then runs | gate on; one-shot per (context, generation) (`approval.py:329-344`) | +| `rm -r dir` (no `-f`), `find -delete`, `: > file` | **auto-approved, never bounced** | classifier requires *both* `-r` and `-f` (`permission.py:538-540`); other forms unclassified | +| AskUserQuestion | auto-dismissed ("no user present") | bound to `is_auto` (`pythinkersoul.py:553`); auto path dismisses | +| EnterPlanMode | auto-approved | bound to `is_auto_approve` (`pythinkersoul.py:544`) | +| ExitPlanMode | auto-approved | bound to `is_auto` (`pythinkersoul.py:532`) → **plan checkpoint defeated** | + +--- + +## 4. Hypotheses + +Split into **BUGS** (genuine defects/inconsistencies worth fixing) and **RISKS** (correct-as-coded, but the combination removes supervision). Each is falsifiable with the test given. Harness patterns: unit = `Approval(state=ApprovalState(...))` (see `tests/core/test_approval_safe_mode.py`); integration = `Runtime.create(...)` (see `tests/core/test_runtime_auto_state.py`). + +### BUGS + +**B1 — The obvious manual combo is more dangerous than the profile. [HIGH]** +`auto_deliberate_destructive_actions` defaults `False` (`config.py:382`); the gate requires it (`approval.py:302`). So `--yolo --auto` with a default config auto-approves every destructive shell command with no bounce, while the purpose-built `autonomous_coding` profile (`config.py:492-493`) is *safer*. The safe path is the obscure one. +- **Test (unit):** build a `Shell` `ToolCall` for `rm -rf /tmp/x`. + - `Approval(ApprovalState(yolo=True, auto=True, auto_deliberate=False))` → `deliberation_gate(call) is None` and `await request(...)` returns `approved=True` (no bounce). + - flip `auto_deliberate=True` → first `request` returns `approved=False, deliberation=True`; re-issue in a later deliberation generation returns `approved=True`. + - **Assertion that documents the defect:** default-config yolo+auto never bounces a destructive command. + +**B2 — Plan-mode checkpoint defeated via Enter/Exit binding asymmetry. [MED-HIGH]** +`EnterPlanMode` is bound to `is_auto_approve` (`pythinkersoul.py:544`); `ExitPlanMode` to `is_auto` (`pythinkersoul.py:532`) — different predicates. Under yolo+auto both are true, so the agent enters *and* approves its own plan exit; the human-review checkpoint is nullified. The asymmetry is independently wrong: in **yolo-only** (interactive, not auto) you slip into plan mode silently (`is_auto_approve=True`) but must click to leave (`is_auto=False`). +- **Test (unit):** `ApprovalState(yolo=True, auto=False)` → `is_auto_approve()` True but `is_auto()` False → assert the two plan tools would resolve differently (the bug). +- **Test (integration):** `Runtime.create(yolo=True)` with persisted `auto=True`; enter plan mode; invoke `ExitPlanMode`; assert it returns auto-approved *without* creating a `QuestionRequest`. + +**B3 — Dangerous state persists and silently resumes; no force-off; `--yolo` rewrites trust. [MED]** +`yolo` + `auto` both persist (`session_state.py:16-17`) and re-apply on resume (`agent.py:282,300`) with no re-confirmation, and no CLI flag disables a persisted yolo. **Related:** a raw `--yolo` invocation sets `effective_safe_mode = False` (`agent.py:285`) and `_on_approval_change` writes `session.state.trust.safe_mode = False` back to disk (`agent.py:295`) — so one `--yolo` run silently downgrades the workspace's persisted trust posture (gated on the raw CLI flag, not persisted/config yolo). +- **Test (integration):** set `session.state.approval.yolo=True, .auto=True`; `Runtime.create(..., yolo=False)` → assert resulting `approval.is_yolo()` and `is_auto()` both True (state silently resumed). +- **Test (integration):** `Runtime.create(..., yolo=True)`, trigger a state change (`set_auto(True)`) → assert `session.state.trust.safe_mode is False` persisted. + +**B4 — `autonomous_coding` sets `ask_user_question_policy="never"`, dismissing AskUserQuestion even in interactive sessions. [LOW-MED]** +`config.py:489-491`: policy `"never"` dismisses regardless of `is_auto`. With profile yolo but no auto (user present), the agent still can never ask them. Tangential to yolo+auto; flag as related. +- **Test (tool unit):** policy `"never"`, `is_auto=False` → AskUserQuestion still returns the auto-dismiss note. + +### RISKS (correct-as-coded; the combination is the hazard) + +**R1 — Full unsupervised auto-approve, no checkpoint anywhere. [HIGH]** +`is_auto_approve→True` (`approval.py:230`) + `_unattended_denial_feedback→None` (`approval.py:258`). No tool call ever surfaces to a human. +- **Test (unit):** yolo+auto → `is_auto_approve()` True; `request()` for WriteFile and benign Shell both `approved=True`. + +**R2 — The deliberation gate (when on) is narrow, one-shot, self-supervised. [HIGH]** +(a) covers **only `Shell`** (`permission.py:510-512`); (b) misses `rm -r` without `-f` (`permission.py:538-540`), `> file` truncation, `find -delete`, `curl … | bash`, `mv` overwrite, `chmod -R`, glob/var-hidden `rm -rf`; (c) one-shot — the model "deliberates" for one generation, then re-issues and it runs, with **no human veto** in auto mode. +- **Test (unit):** `shell_destructive_reason("rm -r /tmp/x") is None`; `"find . -delete" is None`; `": > important.db" is None` → all auto-approve under yolo+auto. Documents the gaps. +- **Test (unit):** one-shot generation behavior — bounce → pass (next gen) → bounce again (fingerprint deleted, 3rd gen is a fresh first-sighting). Drive `_current_deliberation_scope` contextvar. + +**R3 — AskUserQuestion auto-dismissed → no escalation at forks. [MED]** +`pythinkersoul.py:553` binds `is_auto`; auto path returns "no user present, make your own decision." The agent cannot escalate a genuinely ambiguous/irreversible decision. Under `auto_deliberate` policy it self-decides via `blind_advisor_verdict` (`deliberation.py:52-92`), which **never raises** — advisor failures silently fall back to the agent deciding alone. +- **Test (tool unit):** yolo+auto, policy `ask_except_auto` → AskUserQuestion returns the dismiss note, non-blocking. + +**R4 — Runaway / cost: no auto-exit, ≤1000 steps/turn + ralph loop, every step auto-approved. [MED]** +Auto mode has no auto-exit; `max_steps_per_turn` default 1000 (`MaxStepsReached`, `pythinkersoul.py:1227`); ralph loop up to `max_ralph_iterations`. YOLO removes all approval friction, so a looping/hallucinating model can execute ~1000 auto-approved (and within R2's gaps, destructive) tool calls per turn unsupervised. +- **Test (property/limit):** assert the only per-turn stop is `max_steps_per_turn`; assert no auto-mode-specific de-escalation exists. + +**R5 — Trust-gate bypass in untrusted workspaces. [HIGH]** +Auto-alone fails closed under `safe_mode` (`is_auto_approve→False` at `approval.py:232`; denial at `approval.py:262`). **YOLO bypasses both** (`approval.py:230,258`). So a cloned/untrusted repo opened with config `default_yolo` (or persisted yolo) + auto gets full auto-approve in a workspace never trusted. Only explicit `/trust off` clears yolo (`ui/shell/slash.py:1415`). +- **Test (unit):** `ApprovalState(yolo=True, auto=True, safe_mode=True)` → `is_auto_approve()` True and `_unattended_denial_feedback(safe_mode_action) is None`. Compare `ApprovalState(auto=True, safe_mode=True)` (no yolo) → `is_auto_approve()` False, feedback returned. Precise asymmetry. + +**R6 — Outside-workspace writes proceed; "reversible" is operationally meaningless unattended. [MED]** +YOLO bypasses the `_EDIT_OUTSIDE_ACTION` guard (`approval.py:258,260`) → writes to `~/.bashrc`, `~/.ssh/authorized_keys`, etc. auto-approve. **Credit where due:** WriteFile/StrReplaceFile *do* create a content restore point unconditionally (`file_restore.py`; `write.py:165`, `replace.py:280`) that works for *any* path including untracked/gitignored/outside-workspace — so the file content is mechanically recoverable and this is **not** a data-loss bug. **But:** (a) no human is present to invoke `/restore`; (b) side effects already fired (a modified shell rc, an added SSH key); (c) restore points are session-scoped and lost with the session. So the trust boundary is bypassed even though content is technically restorable; file tools are also not in the deliberation registry, so there is no bounce either. +- **Test (unit):** yolo+auto → `request(action=_EDIT_OUTSIDE_ACTION)` returns `approved=True`. Compare auto-only (no yolo) → `approved=False` with `_OUTSIDE_WORKSPACE_UNATTENDED_FEEDBACK`. + +--- + +## 4b. Verification status (tests run 2026-06-02) + +| Hypothesis | Status | Evidence | +|---|---|---| +| B1 | **Fixed** (new tests) | `tests/core/test_runtime_auto_state.py::test_default_config_yolo_auto_deliberates_destructive_actions` — default config + yolo+auto now bounces destructive shell calls for deliberation | +| B2 | **Fixed** (new test) | `tests/core/test_plan_mode_auto_approval.py` — Enter/Exit plan-mode tools now use the same unattended predicate | +| B3 | **Fixed** (new tests) | `tests/core/test_resume_safety_notice.py`; `tests/core/test_runtime_auto_state.py::test_yolo_runtime_does_not_persist_safe_mode_downgrade`; `test_no_yolo_forces_yolo_off_over_persisted_state` | +| R2 (one-shot/narrow gate) | **Already covered** | `test_approval_auto.py::test_destructive_action_deliberates_once_then_proceeds_under_auto`, `test_same_generation_duplicate...`, `test_subagent_identical_call...`, `test_unscoped_destructive_calls_always_bounce_fail_closed` | +| R5 (yolo bypasses safe_mode) | **Already covered** | `test_approval_safe_mode.py::test_yolo_overrides_safe_mode`; `test_runtime_auto_state.py::test_unattended_runtime_in_default_safe_mode_denies_without_waiting` (the no-yolo contrast) | +| R6 (outside-workspace) | **Already covered** | `test_approval_auto.py::test_trusted_auto_denies_outside_workspace_write_without_yolo` + `test_explicit_yolo_allows_outside_workspace_auto_write_boundary` | + +Production fixes and regression tests were added for B1, B2, and B3. Focused approval/runtime tests pass; `ruff check` + `ruff format --check` are clean. + +## 5. Severity summary + +| ID | Kind | Severity | One-line | +|---|---|---|---| +| B1 | Bug | HIGH | Manual `--yolo --auto` is more dangerous than the profile (gate off by default) | +| R1 | Risk | HIGH | Full unsupervised auto-approve, no checkpoint | +| R2 | Risk | HIGH | Backstop is narrow, one-shot, self-supervised | +| R5 | Risk | HIGH | YOLO bypasses untrusted-workspace safe_mode | +| B2 | Bug | MED-HIGH | Plan checkpoint defeated; Enter/Exit binding asymmetry | +| B3 | Bug | MED | Dangerous state persists & silently resumes; `--yolo` rewrites trust | +| R3 | Risk | MED | AskUserQuestion dismissed → no escalation | +| R4 | Risk | MED | Runaway: 1000 steps/turn, no auto-exit | +| R6 | Risk | MED | Outside-workspace writes; reversibility moot unattended | +| B4 | Bug | LOW-MED | `autonomous_coding` policy "never" dismisses asks even interactively | + +--- + +## 6. Suggested guardrails (if any of the bugs are confirmed actionable) + +- **B1:** when `yolo and auto` are both set, default `auto_deliberate` to `True` (or warn loudly at startup that the destructive backstop is off). +- **B2:** bind both plan tools to the *same* predicate; require an explicit non-auto confirmation to *exit* plan mode, or document the defeat. +- **B3:** print a one-line banner on resume when yolo/auto are restored from disk; add a `--no-yolo` force-off flag; do not persist a `--yolo`-derived `safe_mode=False` beyond the run. +- **R5:** make YOLO respect `safe_mode` for *untrusted* workspaces (require `/trust` first), or warn. + +*Note: items in §6 are suggestions; what was actually implemented is in §7.* + +--- + +## 7. Fixes implemented (2026-06-02) + +Decided with the user: **B1 = "all unsupervised" scope**; ship **B1 + B2 + B3**. **B4** is resolved as correct-as-designed. + +### B1 — destructive backstop now holds whenever unattended + +`soul/approval.py` `deliberation_gate`: the early-return changed from +`if not self._state.auto_deliberate` to `if not (self._state.auto_deliberate or self.is_auto())`. +A destructive auto-approved action is now bounced once for deliberation whenever **no user +is present** (`is_auto`), regardless of the config flag. The `auto_deliberate` flag now only +*extends* deliberation to the interactive-yolo case (user present, approvals skipped). + +- Consistency: `soul/dynamic_injections/auto_mode.py` now always injects the + destructive-deliberation guidance under auto (the bare `_AUTO_PROMPT` was removed as + orphaned — it could no longer be selected). +- Effect: plain `--auto` (trusted) and manual `--yolo --auto` now match the + `autonomous_coding` profile instead of being more dangerous than it. + +### B2 — plan-mode checkpoint preserved under interactive yolo + +`soul/pythinkersoul.py`: `EnterPlanMode` is now bound to `self._approval.is_auto` (was +`is_auto_approve`), matching `ExitPlanMode`. Interactive `--yolo` no longer silently slips +into plan mode and then blocks the exit; both transitions self-approve only when truly +unattended (`is_auto`). + +### B3 — persisted-state footguns (all three implemented) + +- **B3a (trust corruption — the real bug):** `agent.py` no longer forces + `effective_safe_mode = False` under `--yolo`. Yolo already bypasses safe mode in the + decision path (`is_auto_approve` / `_unattended_denial_feedback` short-circuit on yolo + before reading `safe_mode`), so there was no deadlock to avoid — and the forced `False` + was being persisted back to `session.state.trust.safe_mode`, silently downgrading the + workspace's trust posture. Now `effective_safe_mode = session.state.trust.safe_mode`. +- **B3b (resume notice):** `app.py` `run_shell` adds a WARN welcome-banner item + (`_resumed_unsupervised_notice`) when a resumed session is running yolo and/or auto, so + it is never silently restored from disk. (yolo/auto also already show in the status bar.) +- **B3c (`--no-yolo`):** new CLI flag plumbed cli → `PythinkerCLI.create` → `Runtime.create`; + `effective_yolo = (yolo or persisted) and not no_yolo`, overriding the flag, config + `default_yolo`, and persisted/resumed state. `--no-yolo` beats `--yolo` if both are passed. + +### B4 — resolved as correct-as-designed (no change) + +`autonomous_coding` keeps `ask_user_question_policy="never"`. Switching to `ask_except_auto` +is a **no-op** in every headless context the profile is for (auto/`--print`/`runtime_auto` +→ `is_auto` → both dismiss) and would *contradict* the profile's purpose interactively (an +"autonomous" session would block for input). `"never"` is the deliberate, correct choice. + +### Tests (all RED→GREEN) + +- New: `tests/core/test_plan_mode_auto_approval.py` (B2 binding); + `tests/core/test_resume_safety_notice.py` (B3b). +- `test_runtime_auto_state.py`: B1 backstop + B3a (yolo doesn't corrupt persisted + `safe_mode`) + B3c (`--no-yolo` forces off over persisted yolo). +- `test_approval_auto.py`: gate conditions, flag role, default-auto background-shell + deliberation. `test_auto_injection.py`: prompt selection. The obsolete + `test_plan_mode_enter_exit_predicate_asymmetry` (asserted the *buggy* asymmetry) was + removed — superseded by the binding test. diff --git a/tests/core/test_approval_auto.py b/tests/core/test_approval_auto.py index 56d79136..ac4a38f3 100644 --- a/tests/core/test_approval_auto.py +++ b/tests/core/test_approval_auto.py @@ -205,25 +205,37 @@ def test_unscoped_destructive_calls_always_bounce_fail_closed() -> None: def test_deliberation_gate_conditions() -> None: - """The gate fires only when the feature is on, we would otherwise auto-approve - (auto OR yolo), and the command is destructive.""" + """The gate fires when an irreversible action would be auto-approved and either no + user is present (auto) or the deliberate flag is set.""" rm = _shell_call("rm -rf x") safe = _shell_call("ls -la") - # feature off -> never deliberates (full back-compat) - off = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) - assert off.deliberation_gate(rm) is None + # no user present (auto) -> the destructive backstop holds even with the flag off: + # there is no human to veto, so the model must deliberate once first. + unattended = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) + assert unattended.deliberation_gate(rm) is not None - # human present (not auto, not yolo) -> normal interactive approval shows the rm -rf; - # no self-deliberation needed + # interactive yolo, flag off -> a user IS present (approvals merely skipped), so no + # self-deliberation is forced. + interactive_yolo = Approval(state=ApprovalState(yolo=True, auto=False, auto_deliberate=False)) + assert interactive_yolo.deliberation_gate(rm) is None + + # interactive yolo + flag on -> the flag EXTENDS deliberation to the user-present case. + interactive_yolo_flag = Approval( + state=ApprovalState(yolo=True, auto=False, auto_deliberate=True) + ) + assert interactive_yolo_flag.deliberation_gate(rm) is not None + + # human present, no yolo/auto -> not auto-approved at all; normal approval shows the + # rm -rf, so no self-deliberation is needed. human = Approval(state=ApprovalState(auto=False, auto_deliberate=True)) assert human.deliberation_gate(rm) is None - # yolo + auto_deliberate -> gates AHEAD of the yolo bypass - yolo = Approval(state=ApprovalState(yolo=True, auto_deliberate=True)) - assert yolo.deliberation_gate(rm) is not None + # yolo + auto -> gates AHEAD of the yolo bypass, flag or no flag + yolo_auto = Approval(state=ApprovalState(yolo=True, auto=True, auto_deliberate=False)) + assert yolo_auto.deliberation_gate(rm) is not None - # non-destructive in auto + auto_deliberate -> proceeds untouched + # non-destructive when no user present -> proceeds untouched benign = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) assert benign.deliberation_gate(safe) is None @@ -298,8 +310,31 @@ async def test_request_bounces_destructive_then_approves_retry() -> None: assert second, "one-shot consumed in a later generation: the deliberated retry runs" +async def test_request_bounces_destructive_background_shell_under_default_auto() -> None: + """B1 end-to-end on the path it actually changed: plain auto with the deliberate flag + OFF (the default). A destructive BACKGROUND shell still bounces once and then runs on + the deliberated retry -- it does NOT hit the fail-closed unscoped branch, because the + approval is requested inline at tool-call time, inside the step's deliberation scope, + before the background task is spawned.""" + from tests.conftest import tool_call_context + + approval = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) + args = {"command": "rm -rf build", "run_in_background": True} + with tool_call_context("Shell", arguments=args): + with deliberation_scope("root", 1): + first = await approval.request("Shell", "run command", "Run command `rm -rf build`") + assert not first, "default-auto destructive bg shell is bounced for deliberation" + assert first.deliberation is True + + with deliberation_scope("root", 2): + second = await approval.request("Shell", "run command", "Run command `rm -rf build`") + assert second, "deliberated retry in a later generation runs (no fail-closed loop)" + + def test_approval_state_honors_auto_deliberate_flag() -> None: - on = Approval(state=ApprovalState(auto=True, auto_deliberate=True)) + # With no user present (auto) the destructive backstop is always on, so the flag's + # distinct effect is on the INTERACTIVE-yolo case (a user is present, approvals skipped). + on = Approval(state=ApprovalState(yolo=True, auto=False, auto_deliberate=True)) assert on.deliberation_gate(_shell_call("rm -rf build")) is not None - off = Approval(state=ApprovalState(auto=True, auto_deliberate=False)) + off = Approval(state=ApprovalState(yolo=True, auto=False, auto_deliberate=False)) assert off.deliberation_gate(_shell_call("rm -rf build")) is None diff --git a/tests/core/test_auto_injection.py b/tests/core/test_auto_injection.py index 738d92cc..c73fdc2c 100644 --- a/tests/core/test_auto_injection.py +++ b/tests/core/test_auto_injection.py @@ -6,7 +6,6 @@ from pythinker_code.soul.dynamic_injections.auto_mode import ( _AUTO_INJECTION_TYPE, - _AUTO_PROMPT, _AUTO_PROMPT_DELIBERATE, _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE, AutoModeInjectionProvider, @@ -38,10 +37,12 @@ async def test_injects_when_auto_enabled() -> None: result = await provider.get_injections([], _mock_soul(is_auto=True)) assert len(result) == 1 assert result[0].type == _AUTO_INJECTION_TYPE - assert result[0].content == _AUTO_PROMPT + # Under auto, the destructive backstop is always active, so the guidance always + # surfaces the deliberation behavior (not the bare prompt). + assert result[0].content == _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE assert "auto" in result[0].content.lower() assert "Do NOT call AskUserQuestion" in result[0].content - assert "All tool calls are auto-approved" not in result[0].content + assert "Irreversible auto-approved actions" in result[0].content assert "fail" in result[0].content.lower() @@ -59,7 +60,7 @@ async def test_runtime_auto_injects_non_persistent_prompt() -> None: result = await provider.get_injections([], _mock_soul(is_auto=True, is_auto_flag=False)) assert len(result) == 1 assert result[0].type == _AUTO_INJECTION_TYPE - assert result[0].content == _AUTO_PROMPT + assert result[0].content == _AUTO_PROMPT_DESTRUCTIVE_DELIBERATE async def test_no_injection_when_auto_disabled() -> None: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index eb7738ed..24c76f7a 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -83,7 +83,13 @@ def test_default_config_dump(): "extra_skill_dirs": [], "telemetry": True, "skip_auto_prompt_injection": False, - "tui": {"style": "card", "prompt_history_enabled": True, "turn_recaps": True}, + "tui": { + "style": "card", + "prompt_history_enabled": True, + "turn_recaps": True, + "code_theme": "pythinker-ansi", + "smooth_streaming": True, + }, } ) diff --git a/tests/core/test_plan_mode_auto_approval.py b/tests/core/test_plan_mode_auto_approval.py new file mode 100644 index 00000000..a53cd625 --- /dev/null +++ b/tests/core/test_plan_mode_auto_approval.py @@ -0,0 +1,66 @@ +"""Plan-mode auto-approval binding (Hypothesis B2). + +EnterPlanMode and ExitPlanMode must gate on the SAME predicate (``is_auto`` -- "no user +present"), not on ``is_auto_approve`` (which yolo flips True). Otherwise a yolo-only +session (user present, approvals merely skipped) silently slips *into* plan mode but +still needs a human to *leave* it -- an asymmetric surface that quietly defeats the +plan-review checkpoint. Under genuine auto mode both transitions self-approve because no +human is there to confirm. +""" + +from __future__ import annotations + +from pathlib import Path + +from pythinker_code.soul.agent import Agent, Runtime +from pythinker_code.soul.context import Context +from pythinker_code.soul.pythinkersoul import PythinkerSoul +from pythinker_code.soul.toolset import PythinkerToolset +from pythinker_code.tools.plan import ExitPlanMode +from pythinker_code.tools.plan.enter import EnterPlanMode + + +def _bound_plan_tools(runtime: Runtime, tmp_path: Path) -> tuple[EnterPlanMode, ExitPlanMode]: + """Build a soul with the plan tools so PythinkerSoul binds their auto-approve checkers.""" + toolset = PythinkerToolset() + enter = EnterPlanMode() + exit_ = ExitPlanMode() + toolset.add(enter) + toolset.add(exit_) + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=toolset, + runtime=runtime, + ) + PythinkerSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + return enter, exit_ + + +def test_yolo_only_does_not_auto_confirm_plan_transitions(runtime: Runtime, tmp_path: Path) -> None: + """Yolo-only (user present): neither entering nor leaving plan mode is auto-confirmed, + so the human still reviews the plan.""" + runtime.approval.set_yolo(True) # fixture default is yolo=True; auto stays off + assert runtime.approval.is_yolo() is True + assert runtime.approval.is_auto() is False + + enter, exit_ = _bound_plan_tools(runtime, tmp_path) + + assert enter._is_auto_approve is not None + assert enter._is_auto_approve() is False + assert exit_._should_auto_approve_exit is not None + assert exit_._should_auto_approve_exit() is False + + +def test_unsupervised_auto_self_manages_plan_transitions(runtime: Runtime, tmp_path: Path) -> None: + """Auto mode (no user present): both transitions self-approve -- there is no human to + confirm. This must hold regardless of yolo.""" + runtime.approval.set_auto(True) + assert runtime.approval.is_auto() is True + + enter, exit_ = _bound_plan_tools(runtime, tmp_path) + + assert enter._is_auto_approve is not None + assert enter._is_auto_approve() is True + assert exit_._should_auto_approve_exit is not None + assert exit_._should_auto_approve_exit() is True diff --git a/tests/core/test_resume_safety_notice.py b/tests/core/test_resume_safety_notice.py new file mode 100644 index 00000000..f7ac73f7 --- /dev/null +++ b/tests/core/test_resume_safety_notice.py @@ -0,0 +1,34 @@ +"""Resume safety notice (Hypothesis B3b). + +When a resumed session is running unsupervised (yolo and/or auto), the welcome banner +must surface a warning so the state is never silently restored from disk. +""" + +from __future__ import annotations + +from pythinker_code.app import _resumed_unsupervised_notice + + +def test_no_notice_for_fresh_session() -> None: + assert _resumed_unsupervised_notice(resumed=False, yolo=True, auto=True) is None + + +def test_no_notice_when_not_unsupervised() -> None: + assert _resumed_unsupervised_notice(resumed=True, yolo=False, auto=False) is None + + +def test_notice_names_active_modes_on_resume() -> None: + # The mode label is the prefix before " active —"; assert on that to avoid colliding + # with "auto-approved" / "/auto" later in the message. + yolo_only = _resumed_unsupervised_notice(resumed=True, yolo=True, auto=False) + assert yolo_only is not None and yolo_only.startswith("YOLO active") + assert "actions auto-approved" in yolo_only + + auto_only = _resumed_unsupervised_notice(resumed=True, yolo=False, auto=True) + assert auto_only is not None and auto_only.startswith("auto active") + assert "interactive approvals still required" in auto_only + assert "actions auto-approved" not in auto_only + + both = _resumed_unsupervised_notice(resumed=True, yolo=True, auto=True) + assert both is not None and both.startswith("YOLO + auto active") + assert "actions auto-approved" in both diff --git a/tests/core/test_runtime_auto_state.py b/tests/core/test_runtime_auto_state.py index d39b33b9..55d09678 100644 --- a/tests/core/test_runtime_auto_state.py +++ b/tests/core/test_runtime_auto_state.py @@ -167,3 +167,123 @@ async def test_runtime_set_auto_persists_to_session_state( assert runtime.approval.is_auto() is True assert session.state.approval.auto is True + + +@pytest.mark.asyncio +async def test_yolo_run_does_not_corrupt_persisted_safe_mode( + config, + session, + lightweight_runtime_create, +) -> None: + """Hypothesis B3a: a ``--yolo`` invocation must not silently downgrade the workspace's + persisted trust posture. + + Yolo bypasses safe mode *functionally* (is_auto_approve / _unattended_denial_feedback + short-circuit on yolo before reading safe_mode), so there is no need to force + ``safe_mode=False`` at runtime — and doing so used to get persisted back to + ``session.state.trust.safe_mode`` via the on-change callback, corrupting trust state. + """ + session.state.trust.safe_mode = True + + runtime = await Runtime.create( + config, + OAuthManager(config), + llm=None, + session=session, + yolo=True, + ) + + # Yolo still auto-approves — no deadlock behind safe mode. + assert runtime.approval.is_yolo() is True + assert runtime.approval.is_auto_approve() is True + + # An approval-state change persists state; the workspace trust posture must survive. + runtime.approval.set_auto(True) + assert session.state.trust.safe_mode is True + + +@pytest.mark.asyncio +async def test_no_yolo_forces_yolo_off_over_persisted_state( + config, + session, + lightweight_runtime_create, +) -> None: + """Hypothesis B3c: ``--no-yolo`` forces yolo off for the run even when persisted state + (or config ``default_yolo``) would otherwise enable it.""" + session.state.approval.yolo = True + + runtime = await Runtime.create( + config, + OAuthManager(config), + llm=None, + session=session, + yolo=False, + no_yolo=True, + ) + + assert runtime.approval.is_yolo() is False + + runtime.approval.set_auto(True) + assert session.state.approval.yolo is True + + +@pytest.mark.asyncio +async def test_default_config_yolo_auto_deliberates_destructive_actions( + config, + session, + lightweight_runtime_create, +) -> None: + """Hypothesis B1 (fixed): the obvious manual combo (``--yolo --auto``, default + config) now has the destructive backstop. + + ``auto_deliberate_destructive_actions`` still defaults False (config.py:381-382), but + the deliberation gate fires whenever an irreversible action would be auto-approved + with no user present (``is_auto``), regardless of the config flag. So a destructive + ``rm -rf`` is bounced once for deliberation instead of running blind. This matches the + purpose-built ``autonomous_coding`` profile rather than being more dangerous than it. + """ + assert config.auto_deliberate_destructive_actions is False # still the default + session.state.approval.auto = True + + runtime = await Runtime.create( + config, + OAuthManager(config), + llm=None, + session=session, + yolo=True, + ) + + assert runtime.approval.is_yolo() is True + assert runtime.approval.is_auto() is True + assert runtime.approval.is_auto_approve() is True + # No user present + would auto-approve a destructive action -> the backstop bounces it. + assert runtime.approval.deliberation_gate(_shell_call("rm -rf build")) is not None + + +@pytest.mark.asyncio +async def test_runtime_create_silently_resumes_both_yolo_and_auto( + config, + session, + lightweight_runtime_create, +) -> None: + """Hypothesis B3: a session that persisted both yolo and auto silently resumes + fully unsupervised, with no flags passed and no re-confirmation. + + ``effective_yolo = yolo or session.state.approval.yolo`` (agent.py:282) and auto is + read straight from persisted state (agent.py:300). There is no CLI flag to force a + persisted yolo off. + """ + session.state.approval.yolo = True + session.state.approval.auto = True + + runtime = await Runtime.create( + config, + OAuthManager(config), + llm=None, + session=session, + yolo=False, # no --yolo on this invocation + ) + + assert runtime.approval.is_yolo() is True # restored from disk regardless + assert runtime.approval.is_auto() is True + assert runtime.approval.is_auto_approve() is True diff --git a/tests/ui_and_conv/test_code_theme_opt_in.py b/tests/ui_and_conv/test_code_theme_opt_in.py new file mode 100644 index 00000000..e4d146e5 --- /dev/null +++ b/tests/ui_and_conv/test_code_theme_opt_in.py @@ -0,0 +1,97 @@ +"""Opt-in Pygments code-fence theme. + +Default (``pythinker-ansi``) keeps today's terminal-adaptive, transparent look. +Setting ``config.tui.code_theme`` to a stock Pygments style renders assistant +code fences with that style on a solid dark background block, with zero change +to any other rendering. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from rich.console import Console + +from pythinker_code.config import TUIConfig +from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown +from pythinker_code.utils.rich.syntax import ( + PYTHINKER_ANSI_THEME, + PYTHINKER_ANSI_THEME_NAME, + PythinkerSyntax, + get_active_code_theme, + resolve_code_theme, + set_active_code_theme, +) + +# 24-bit background escape for Monokai's `#272822` (rgb 39,40,34). +_MONOKAI_BG = "48;2;39;40;34" +_FENCE = "```python\nimport os\n```\n" + + +@pytest.fixture(autouse=True) +def _restore_active_code_theme() -> Iterator[None]: + """Keep the process-wide code theme from leaking between tests.""" + saved = get_active_code_theme() + try: + yield + finally: + set_active_code_theme(saved) + + +def _render_ansi(text: str, *, width: int = 60) -> str: + console = Console(force_terminal=True, color_system="truecolor", width=width) + with console.capture() as capture: + console.print(PythinkerMarkdown(text)) + return capture.get() + + +def test_default_theme_keeps_transparent_ansi_look() -> None: + set_active_code_theme(PYTHINKER_ANSI_THEME_NAME) + output = _render_ansi(_FENCE) + + # No stock-style dark block; the ANSI theme carries no truecolor background. + assert _MONOKAI_BG not in output + assert "import" in output + + +def test_opt_in_stock_theme_paints_solid_dark_block() -> None: + set_active_code_theme("monokai") + output = _render_ansi(_FENCE) + + # Code fence now renders on Monokai's own background. + assert _MONOKAI_BG in output + assert "import" in output + + +def test_active_code_theme_round_trips() -> None: + set_active_code_theme("dracula") + assert get_active_code_theme() == "dracula" + + +def test_pythinker_syntax_uses_active_code_theme() -> None: + set_active_code_theme("monokai") + console = Console(force_terminal=True, color_system="truecolor", width=60) + with console.capture() as capture: + console.print(PythinkerSyntax("import os", "python")) + + assert _MONOKAI_BG in capture.get() + + +def test_resolve_code_theme_maps_only_the_sentinel() -> None: + # Sentinel resolves to the ANSI SyntaxTheme instance; stock names stay strings + # (this string-vs-instance distinction is what the renderer branches on). + assert resolve_code_theme(PYTHINKER_ANSI_THEME_NAME) is PYTHINKER_ANSI_THEME + assert resolve_code_theme("monokai") == "monokai" + + +def test_tui_config_accepts_sentinel_and_stock_styles() -> None: + assert TUIConfig().code_theme == PYTHINKER_ANSI_THEME_NAME + assert TUIConfig(code_theme="monokai").code_theme == "monokai" + # Case-insensitive convenience: a known style name is normalized to lower. + assert TUIConfig(code_theme="Monokai").code_theme == "monokai" + + +def test_tui_config_rejects_unknown_code_theme() -> None: + with pytest.raises(ValueError, match="Unknown code_theme"): + TUIConfig(code_theme="definitely-not-a-real-style") diff --git a/tests/ui_and_conv/test_live_view_todos.py b/tests/ui_and_conv/test_live_view_todos.py index aaab76a6..3b918945 100644 --- a/tests/ui_and_conv/test_live_view_todos.py +++ b/tests/ui_and_conv/test_live_view_todos.py @@ -191,7 +191,7 @@ def test_active_todo_activity_line_uses_stable_label_not_shimmer() -> None: "Implement pinned todos", elapsed_s=0.88, width=100, shimmer_label=False ) - active_color = _color_hex(tui_rich_style("activity_label").color) + active_color = _color_hex(tui_rich_style("accent").color) marker_style = Style.parse(line.style) if isinstance(line.style, str) else line.style assert marker_style.color == tui_rich_style("activity_spinner").color assert _span_colors_for(line, "Implement pinned todos") == {active_color} diff --git a/tests/ui_and_conv/test_shell_motion_shimmer.py b/tests/ui_and_conv/test_shell_motion_shimmer.py index fbbb8bed..a1705fc4 100644 --- a/tests/ui_and_conv/test_shell_motion_shimmer.py +++ b/tests/ui_and_conv/test_shell_motion_shimmer.py @@ -43,7 +43,7 @@ def test_shimmer_varies_over_time_when_motion_enabled(monkeypatch): assert first != later or first != _SHIMMER_BASE.lower() -def test_prompt_shimmer_fragments_share_silver_sheen_palette(monkeypatch): +def test_prompt_shimmer_fragments_share_ember_ramp_palette(monkeypatch): monkeypatch.delenv("PYTHINKER_REDUCED_MOTION", raising=False) set_active_theme("dark") diff --git a/tests/ui_and_conv/test_shell_prompt_echo.py b/tests/ui_and_conv/test_shell_prompt_echo.py index 2144d9e7..dc0c163e 100644 --- a/tests/ui_and_conv/test_shell_prompt_echo.py +++ b/tests/ui_and_conv/test_shell_prompt_echo.py @@ -152,7 +152,11 @@ def test_card_style_user_echo_renders_transcript_prompt_symbol() -> None: finally: set_active_tui_style(original) - assert plain == "\n❯ apply\n" + lines = plain.split("\n") + assert lines[0] == "" + # The submitted prompt sits in a full-width tinted block, so the row carries + # surrounding padding around the marker and message. + assert lines[1].strip() == "❯ apply" assert "✨" not in plain @@ -162,9 +166,11 @@ def test_user_echo_wraps_continuation_under_text_start() -> None: lines = plain.splitlines() assert lines[0] == "" - assert lines[1].startswith("❯ ") - assert lines[2].startswith(" ") - assert not lines[2].startswith("❯") + assert lines[1].lstrip().startswith("❯ ") + # Continuation wraps under the message text, not under the prompt marker, + # and stays column-aligned with the first line inside the tinted block. + assert not lines[2].lstrip().startswith("❯") + assert lines[1].index("abcdefghij") == lines[2].index("klmnopqrst") def test_user_echo_renders_pasted_markdown_tables() -> None: @@ -175,13 +181,33 @@ def test_user_echo_renders_pasted_markdown_tables() -> None: "| high | High | #cc704b |\n" ) plain = render_plain(rendered, width=72) + lines = plain.splitlines() - assert plain.startswith("\n❯ ┌") + assert lines[0] == "" + assert lines[1].lstrip().startswith("❯ ┌") assert "│ Step" in plain assert "#475569" in plain assert "| --- |" not in plain +def test_user_echo_wraps_message_in_tinted_block() -> None: + from rich.console import Console + + from pythinker_code.ui.theme import tui_rich_style + + expected = tui_rich_style("user_message_bg").bgcolor + assert expected is not None + + console = Console(force_terminal=True, color_system="truecolor", width=40) + lines = console.render_lines(render_user_echo_text("apply"), console.options) + + # The submitted message itself, not just surrounding padding, is painted on + # the shared user_message_bg block. + apply_segment = next(segment for line in lines for segment in line if "apply" in segment.text) + assert apply_segment.style is not None + assert apply_segment.style.bgcolor == expected + + 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_stream_pacing.py b/tests/ui_and_conv/test_stream_pacing.py new file mode 100644 index 00000000..dec8b8ba --- /dev/null +++ b/tests/ui_and_conv/test_stream_pacing.py @@ -0,0 +1,109 @@ +"""Paced reveal of streamed composing text (smooth streaming). + +Bursty LLM deltas are buffered and revealed gradually by ``reveal_tick`` so text +flows smoothly instead of landing in delta-sized clumps, while keeping up with a +fast model. Unpaced and thinking blocks reveal immediately (legacy behavior). +""" + +from __future__ import annotations + +import pytest + +from pythinker_code.ui.shell.visualize._blocks import ( + _ContentBlock, + set_smooth_streaming, + smooth_streaming_enabled, +) + +# Single markdown block (no committable boundary) so reveal never triggers a +# console.print() commit during the test. +_TEXT = "the quick brown fox jumps over the lazy dog several times in a row" + + +@pytest.fixture(autouse=True) +def _restore_smooth_streaming_flag(): + """Keep the process-global smooth-streaming flag isolated for future tests.""" + previous = smooth_streaming_enabled() + try: + yield + finally: + set_smooth_streaming(previous) + + +def _drain(block: _ContentBlock) -> None: + """Tick until fully revealed (bounded loop guards against a stuck cursor).""" + for _ in range(10_000): + if not block.reveal_tick(): + return + raise AssertionError("reveal_tick did not converge") + + +def test_paced_block_buffers_until_ticked() -> None: + block = _ContentBlock(is_think=False, paced=True) + block.append(_TEXT) + # Nothing is revealed until a tick fires. + assert block._revealed_len == 0 + assert block._pending_text() == "" + + assert block.reveal_tick() is True + assert 0 < block._revealed_len < len(_TEXT) + + +def test_paced_reveal_is_monotonic_and_bounded() -> None: + block = _ContentBlock(is_think=False, paced=True) + block.append(_TEXT) + last = 0 + for _ in range(50): + block.reveal_tick() + assert last <= block._revealed_len <= len(block.raw_text) + last = block._revealed_len + assert block._revealed_len == len(_TEXT) + + +def test_paced_reveal_advances_by_display_cells_for_cjk() -> None: + from rich.cells import cell_len + + block = _ContentBlock(is_think=False, paced=True) + block.append("你好") + + assert block.reveal_tick() is True + revealed = block.raw_text[: block._revealed_len] + assert revealed == "你" + assert cell_len(revealed) == 2 + + +def test_paced_reveal_eventually_shows_all_text() -> None: + block = _ContentBlock(is_think=False, paced=True) + block.append(_TEXT) + _drain(block) + assert block._revealed_len == len(_TEXT) + # No text is stranded: committed prefix + revealed pending == full buffer. + assert block.raw_text[: block._committed_len] + block._pending_text() == _TEXT + + +def test_reveal_all_reveals_everything() -> None: + block = _ContentBlock(is_think=False, paced=True) + block.append(_TEXT) + block.reveal_tick() + assert block._revealed_len < len(_TEXT) + + assert block.reveal_all() is True + assert block._revealed_len == len(_TEXT) + # Already revealed -> no further change. + assert block.reveal_all() is False + + +def test_unpaced_block_reveals_immediately() -> None: + block = _ContentBlock(is_think=False, paced=False) + block.append(_TEXT) + assert block._revealed_len == len(_TEXT) + # Unpaced blocks ignore the reveal tick entirely. + assert block.reveal_tick() is False + + +def test_thinking_block_is_never_paced() -> None: + # paced=True is requested, but thinking blocks opt out (text reveals at once). + block = _ContentBlock(is_think=True, paced=True) + block.append(_TEXT) + assert block._revealed_len == len(_TEXT) + assert block.reveal_tick() is False diff --git a/tests/ui_and_conv/test_streaming_content_block.py b/tests/ui_and_conv/test_streaming_content_block.py index fe20d3c8..82924488 100644 --- a/tests/ui_and_conv/test_streaming_content_block.py +++ b/tests/ui_and_conv/test_streaming_content_block.py @@ -211,6 +211,36 @@ def test_mixed_accumulation(self): block.append("world") # 1.25 assert block._token_count == pytest.approx(1.75) + def test_activity_snapshot_uses_recent_token_rate_window(self, monkeypatch): + import importlib + + blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") + + class Clock: + now = 0.0 + + def monotonic(self) -> float: + return self.now + + clock = Clock() + monkeypatch.setattr(blocks_mod.time, "monotonic", clock.monotonic) + + block = _ContentBlock(is_think=False) + block.append("a" * 40) # 10 tokens + assert block._activity_snapshot("Composing").token_rate is None + + clock.now = 0.5 + block.append("b" * 40) # 20 cumulative tokens, 2 samples + assert block._activity_snapshot("Composing").token_rate is None + + clock.now = 1.0 + block.append("c" * 40) # 30 cumulative tokens over the 0.0-1.0s window + assert block._activity_snapshot("Composing").token_rate == 20 + + clock.now = 2.0 + block.append("d" * 40) # Oldest sample is trimmed; use the recent 1.5s window. + assert block._activity_snapshot("Composing").token_rate == 13 + def test_composing_live_label_uses_professional_activity_wording(): block = _ContentBlock(is_think=False) @@ -348,6 +378,34 @@ def test_report_fence_continuation_keeps_gap_after_streamed_prose(self): assert output.startswith("\n") assert "Deep Code Scan Results" in output + def test_streamed_prose_blocks_match_single_pass_spacing(self, monkeypatch): + """Regression: streamed multi-paragraph bodies used to render every + paragraph crammed onto consecutive lines. Each committed block and the + final tail must keep the one-row gap a single markdown pass puts + between blocks.""" + import importlib + + # ``visualize`` re-exports a function of the same name that shadows the + # submodule for attribute walking, so resolve the module via sys.modules. + blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") + rec = Console(record=True, width=80, color_system=None) + monkeypatch.setattr(blocks_mod, "console", rec) + + block = _ContentBlock(is_think=False) + body = ( + "First paragraph here.\n\nSecond paragraph here.\n\nThird and final paragraph here.\n" + ) + for ch in body: # char-by-char: the worst case for commit seams + block.append(ch) + rec.print(block.compose_final()) + + lines = [line.rstrip() for line in rec.export_text().splitlines()] + markers = ("First paragraph", "Second paragraph", "Third and final") + idxs = [next(i for i, line in enumerate(lines) if marker in line) for marker in markers] + for first, second in zip(idxs, idxs[1:], strict=False): + assert second - first >= 2, f"paragraphs at lines {first},{second} are crammed" + assert any(lines[j] == "" for j in range(first + 1, second)) + def test_composing_no_commit_without_newline(self): block = _ContentBlock(is_think=False) block.append("just some text without newlines") @@ -405,6 +463,55 @@ def test_bullet_printed_once(self): # --------------------------------------------------------------------------- +class TestProductionPathBoundaryContract: + """Validate that _ContentBlock never commits a GFM table header row + without its data row.""" + + _FULL_TABLE = "Intro paragraph.\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\nAfter.\n" + + @staticmethod + def _assert_no_mid_table_commit(block: _ContentBlock) -> None: + # Check at the offset layer — never at rendered output (Rich converts + # GFM table syntax to box-drawing chars, so "---" won't appear in text). + table_header_start = block.raw_text.find("| A | B |") + table_data_end_idx = block.raw_text.find("| 1 | 2 |") + if table_header_start == -1 or table_data_end_idx == -1: + return + table_data_end = table_data_end_idx + len("| 1 | 2 |") + committed_at = block._committed_len + assert not (table_header_start < committed_at < table_data_end), ( + f"header-only table committed at offset {committed_at} " + f"before data row arrived (data row ends at {table_data_end})" + ) + + def test_unpaced_composing_block_does_not_commit_table_mid_row(self, monkeypatch): + import importlib + + blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") + rec = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(blocks_mod, "console", rec) + + block = _ContentBlock(is_think=False) + for ch in self._FULL_TABLE: + block.append(ch) + self._assert_no_mid_table_commit(block) + + def test_paced_composing_block_does_not_commit_table_mid_row(self, monkeypatch): + import importlib + + blocks_mod = importlib.import_module("pythinker_code.ui.shell.visualize._blocks") + rec = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(blocks_mod, "console", rec) + + block = _ContentBlock(is_think=False, paced=True) + for ch in self._FULL_TABLE: + block.append(ch) + block.reveal_tick() + self._assert_no_mid_table_commit(block) + while block.reveal_tick(): + self._assert_no_mid_table_commit(block) + + class TestShowThinkingStream: """The ``show_thinking_stream`` flag opts back into the pre-1.32 behavior where thinking content is rendered as a 6-line scrolling preview during 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 5067264a..f5296df6 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -881,7 +881,7 @@ def test_ask_user_renders_question_and_options(): }, ) assert "● Ask 1 question" in rendered - assert "● Ask 1 question\n\n? Which auth method?" in rendered + assert "● Ask 1 question\n\n● Which auth method?" in rendered assert "OAuth" in rendered assert "API key" in rendered diff --git a/tests/ui_and_conv/test_tui_theme_tokens.py b/tests/ui_and_conv/test_tui_theme_tokens.py index c118a1ec..e0cddadb 100644 --- a/tests/ui_and_conv/test_tui_theme_tokens.py +++ b/tests/ui_and_conv/test_tui_theme_tokens.py @@ -39,9 +39,9 @@ def test_dark_tokens_have_brand_values(): assert t.error == "#EF5E62" assert t.thinking_text == "#C0C0C0" # lighter neutral grey, not purple-tinted muted assert t.thinking_text != t.muted - assert t.activity_verb == "#C8B176" # champagne activity verb - assert t.activity_verb_mid == "#E1CC94" - assert t.activity_verb_highlight == "#EEF2F7" + assert t.activity_verb == "#EE9983" # muted coral resting (robot antenna accent) + assert t.activity_verb_mid == "#F4B5A5" # light coral + assert t.activity_verb_highlight == "#FBD9CE" # soft coral spark assert t.activity_spinner == "#B8C0CC" assert t.tool_title == t.activity_label assert t.tool_pending_bg == "#1B2230" @@ -57,9 +57,9 @@ def test_light_tokens_have_brand_values(): assert t.error == "#C0392B" assert t.thinking_text == "#7A7A7A" # lighter neutral grey, not blue/purple muted assert t.thinking_text != t.muted - assert t.activity_verb == "#7A5C24" # contrast-safe bronze activity verb - assert t.activity_verb_mid == "#8A6A2D" - assert t.activity_verb_highlight == "#213853" + assert t.activity_verb == "#C56B4F" # contrast-safe coral activity verb + assert t.activity_verb_mid == "#B0573C" # deeper coral + assert t.activity_verb_highlight == "#8F3A26" # deep-coral spark (max contrast on light) assert t.activity_spinner == "#6B7280" assert t.tool_title == t.activity_label assert t.tool_pending_bg == "#EFE7E8" diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index d2573736..377858eb 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -240,9 +240,22 @@ def test_pyinstaller_datas(): def test_pyinstaller_hiddenimports(): + import pkgutil + + import pygments.styles + from pythinker_code.utils.pyinstaller import hiddenimports - assert sorted(hiddenimports) == snapshot( + # Pygments style list is owned by the Pygments package and changes with upgrades; + # enumerate actual sub-modules (matching collect_submodules) rather than the style + # registry names (get_all_styles uses hyphens, modules use underscores). + expected_pygments = {"pygments.styles"} | { + f"pygments.styles.{mod.name}" for mod in pkgutil.iter_modules(pygments.styles.__path__) + } + assert expected_pygments <= set(hiddenimports) + + project_entries = sorted(set(hiddenimports) - expected_pygments) + assert project_entries == snapshot( [ "pythinker_code.cli._lazy_group", "pythinker_code.cli.debug",