"""Render Anthropic ``Message`` (and individual user turns) into markdown. The renderer is one-way: it produces the human-facing artifact in the vault. The parser strips tool/thinking callouts when reshaping the file for backend replay — so what we write here is purely for the human reader (and for the cross-frontend logger, which materializes turns from other frontends). """ from __future__ import annotations import json import re from typing import TYPE_CHECKING, Any, cast from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock if TYPE_CHECKING: from collections.abc import Iterable from anthropic.types import MessageParam __all__ = [ "FENCE", "USER_SCAFFOLD", "append_to_body", "render_assistant_message", "render_user_text", "summarize_tool_input", ] # Empty ``### User:`` block appended after each assistant reply so the # human has an obvious place to type the next turn. Parser drops empty # user blocks, so this doesn't re-trigger dispatch on its own. USER_SCAFFOLD = "### User:\n" # Default 4-backtick fence so tool results that contain literal ```` ``` ```` # don't collide. JSON inputs use 3 backticks because they almost never # contain ``` and we get language syntax highlighting in Obsidian for free. FENCE = "````" # Used when a tool input mentions a path/file we can dangle in the # callout title — purely cosmetic. _PATH_KEYS = ("path", "file", "filename", "url", "command", "query") def render_user_text(content: str) -> str: r"""Render one user-spoken turn as ``### User:\n\n``.""" return f"### User:\n\n{content.strip()}\n" def render_assistant_message(message: Message) -> str: """Render an assistant ``Message`` (with content blocks) into a turn block. Blocks render in their original order: * ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout * ``TextBlock`` → plain text (the spoken answer) * ``ToolUseBlock`` → ``> [!tool]- `` callout with the ``input`` JSON quoted inside. Tool *results* are not persisted — see module docstring on ``parser.py`` for why. Blank lines separate adjacent blocks; trailing newline guarantees the next ``---`` / ``### User:`` marker lands on its own line. """ parts: list[str] = ["### Assistant:", ""] for block in message.content: parts.extend(_render_block(block)) parts.append("") return "\n".join(parts).rstrip() + "\n" def render_user_param(param: MessageParam) -> str: """Render a ``MessageParam`` user message into a ``### User:`` block. Used by the cross-frontend logger when materializing turns from other frontends. Tool_result blocks in the content list are dropped silently — the markdown view doesn't track them (see ``parser.py``). """ content = param.get("content", "") if isinstance(content, str): text = content else: # The Anthropic SDK types ``content`` as a union of typed-dict # *Param classes plus pydantic block models — both shapes appear # in practice (raw incoming JSON yields dicts, SDK-built params # yield BaseModels). Treat each entry as a dict-like and pull # ``text`` opportunistically. chunks = [ str(blk.get("text", "")) for blk in content if isinstance(blk, dict) and blk.get("type") == "text" ] text = "\n\n".join(chunks) return render_user_text(text) def append_to_body(existing: str, new_block: str) -> str: """Append ``new_block`` to ``existing`` with a decorative HR separator. Preserves the original body verbatim (whitespace, callouts, any formatting the human added). The HR is purely visual: parser ignores it. """ head = existing.rstrip() if head: return f"{head}\n\n---\n\n{new_block}" return new_block def summarize_tool_input(name: str, tool_input: object) -> str: """Build the ``[!tool]- `` title string. Tries to pick a single salient field (``path``, ``command``, etc.) from the input dict so the collapsed callout shows something meaningful in Obsidian. Falls back to just the tool name. """ if not isinstance(tool_input, dict): return name # Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the # SDK's runtime value is always a JSON dict (str→Any), so a local # cast keeps the rest of the function readable without sprinkling # per-line type narrowing on every ``.get`` call. d = cast("dict[str, Any]", tool_input) for key in _PATH_KEYS: value = d.get(key) if isinstance(value, str) and value: short = value if len(value) <= 60 else value[:57] + "..." return f"{name} · {short}" return name # ---- internals --------------------------------------------------------- def _render_block(block: object) -> Iterable[str]: if isinstance(block, TextBlock): text = (block.text or "").strip() if text: yield text return if isinstance(block, ThinkingBlock): yield from _render_thinking(block.thinking or "") return if isinstance(block, ToolUseBlock): yield from _render_tool_use(block) return # Unknown block type — skip silently rather than corrupting the file. def _render_thinking(text: str) -> Iterable[str]: yield "> [!thinking]-" for line in text.strip().splitlines() or [""]: yield f"> {line}" if line else ">" def _render_tool_use(block: ToolUseBlock) -> Iterable[str]: title = summarize_tool_input(block.name, block.input) yield f"> [!tool]- {title}" yield "> **input:**" yield "> ```json" pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True) for line in pretty.splitlines(): yield f"> {line}" if line else ">" yield "> ```" def adaptive_fence(content: str) -> str: """Return a backtick fence at least one longer than the longest run in ``content``. Currently unused (tool *results* aren't persisted yet) — kept here so when result capture lands the rendering side already has the primitive. """ longest = 0 for match in re.finditer(r"`+", content): longest = max(longest, len(match.group(0))) return "`" * max(3, longest + 1)