Source code for ralph.agents.parsers.nanocoder

"""Nanocoder interactive output parser."""

from __future__ import annotations

import re
from typing import TYPE_CHECKING

from ralph.display.vt_normalizer import normalize_vt_text

from .agent_output_line import AgentOutputLine
from .generic import GenericParser

if TYPE_CHECKING:
    from collections.abc import Iterator

    from ralph.agents.idle_watchdog import SubagentPidRegistry

_MAX_STATUS_EVENTS = 8
_MAX_TEXT_EVENTS = 64
_TURN_BOUNDARY_MARKER = "[claude turn boundary]"
_CTX_PERCENT_RE = re.compile(r"\bctx:\s*\d+%")
_WHITESPACE_RE = re.compile(r"\s+")
_SPINNER_STATUS_RE = re.compile(r"^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+")
_MULTIPLICATION_SIGN = chr(215)
_EXECUTED_TOOL_RE = re.compile(
    rf"^⚒\s+Executed\s+(?P<tool>\S+)(?:\s+{_MULTIPLICATION_SIGN}\s+\d+)?$"
)


def _status_signature(text: str) -> str:
    stable = _CTX_PERCENT_RE.sub("ctx:%", text)
    return _WHITESPACE_RE.sub(" ", stable).strip()


[docs] class NanocoderParser(GenericParser): """Parser for Nanocoder's interactive PTY stream. Nanocoder redraws a terminal UI while it works. Many PTY frames contain only cursor movement, erase codes, or whitespace after VT normalization. The generic parser turns those frames into visible ``raw`` events, which floods operator output. Nanocoder keeps GenericParser's plain-text tool marker support, but suppresses control-only frames. """ def __init__( self, subagent_pid_registry: SubagentPidRegistry | None = None, subagent_source_label: str | None = None, ) -> None: super().__init__( subagent_pid_registry=subagent_pid_registry, subagent_source_label=subagent_source_label, ) self._status_signatures: tuple[str, ...] = () self._text_signatures: tuple[str, ...] = () def _text_seen(self, signature: str) -> bool: if signature in self._text_signatures: return True if len(self._text_signatures) >= _MAX_TEXT_EVENTS: self._text_signatures = (*self._text_signatures[1:], signature) else: self._text_signatures = (*self._text_signatures, signature) return False def _is_status_line(self, normalized: str) -> bool: if _SPINNER_STATUS_RE.search(normalized): return True lowered = normalized.lower() return ( "waiting for mcp servers" in lowered or "waiting for chat to complete" in lowered or " · " in normalized or normalized.startswith("~/Library/Preferences/nanocoder/") ) def _classify_non_json_line(self, stripped: str) -> Iterator[AgentOutputLine]: normalized = normalize_vt_text(stripped).strip() yield from self._flush_accumulator() if not normalized or normalized == _TURN_BOUNDARY_MARKER: return if normalized.startswith("[plain] tool:"): yield from super()._classify_non_json_line(stripped) return signature = _status_signature(normalized) tool_match = _EXECUTED_TOOL_RE.search(normalized) is_status = self._is_status_line(normalized) if tool_match is not None: yield AgentOutputLine( type="tool_use", content=tool_match.group("tool"), raw=stripped, metadata={"event": "nanocoder_tool_execution"}, ) elif not is_status and not self._text_seen(signature): yield AgentOutputLine( type="text", content=normalized, raw=stripped, metadata={"event": "nanocoder_text"}, ) elif ( is_status and signature not in self._status_signatures and len(self._status_signatures) < _MAX_STATUS_EVENTS ): self._status_signatures = (*self._status_signatures, signature) yield AgentOutputLine( type="status", content=normalized, raw=stripped, metadata={"event": "interactive_tui"}, )