"""Shared transcript rendering used by both UIs.
Both ``local_ui.py`` (driving a raw ``opencode serve``) and the deployed
``server/gradio_ui.py`` (driving an in-sandbox ``opencode serve`` through
the env's MCP tools) consume the same opencode message+parts shape:
messages: [
{
"info": {id, role, sessionID, time, ...},
"parts": [
{"type": "step-start", ...},
{"type": "reasoning", "text": ..., "id": ...},
{"type": "text", "text": ..., "id": ...},
{"type": "tool", "tool": "...", "state": {status, input, output}, ...},
{"type": "step-finish", "tokens": {...}, ...},
],
},
...
]
or the flat SSE form:
events: [{"type": "message.part.updated", "properties": {"part": {...}}}, ...]
Both reduce to an ordered list of parts keyed on ``part.id``.
"""
from __future__ import annotations
import html as _html
import json
from typing import Any
# ── Part collection ────────────────────────────────────────────────────────
def collect_parts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Reduce SSE ``message.part.updated`` frames to latest snapshot per ``part.id``.
Used by ``local_ui.py`` (direct SSE consumer).
"""
order: list[str] = []
latest: dict[str, dict[str, Any]] = {}
for ev in events:
if ev.get("type") != "message.part.updated":
continue
p = (ev.get("properties") or {}).get("part") or {}
pid = p.get("id")
if not pid:
continue
if pid not in latest:
order.append(pid)
latest[pid] = p
return [latest[i] for i in order]
def collect_parts_from_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Flatten the ``GET /session/:id/message`` shape into an ordered parts list.
Used by the deployed Gradio UI which polls via ``get_messages`` MCP tool.
Message order is preserved; within a message the server returns parts in
emission order so no further sorting is needed.
"""
parts: list[dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict):
continue
for p in m.get("parts") or []:
if isinstance(p, dict):
parts.append(p)
return parts
# ── Rendering ──────────────────────────────────────────────────────────────
def _esc(s: Any) -> str:
return _html.escape("" if s is None else str(s))
def _cap(s: str, n: int = 6000) -> str:
if len(s) <= n:
return s
return s[:n] + f"\n… ({len(s) - n} chars hidden)"
def _todo_icon(status: str | None) -> str:
return {"completed": "✅", "in_progress": "🔄"}.get(status or "", "⏳")
def fmt_tool(name: str, state: dict[str, Any], raw: dict[str, Any]) -> str:
"""Per-tool card — mirrors opencode's own UI shapes."""
status = (state or {}).get("status") or "?"
inp = (state or {}).get("input") or raw.get("input") or {}
out = (state or {}).get("output") or raw.get("output") or ""
badge = {"completed": "ok", "error": "err", "running": "run"}.get(status, "")
if name == "read":
summary = f"📖 read {_esc(inp.get('filePath') or inp.get('path'))}"
body = f"
{_esc(_cap(str(out)))}"
elif name == "write":
path = inp.get("filePath") or inp.get("path")
content = inp.get("content") or ""
summary = f"✍️ write {_esc(path)} ({len(content)} chars)"
body = f"{_esc(_cap(content))}"
elif name == "edit":
path = inp.get("filePath") or inp.get("path")
old = inp.get("oldString") or ""
new = inp.get("newString") or ""
summary = f"✏️ edit {_esc(path)}"
body = (
f"{_esc(_cap(old, 3000))}"
f"{_esc(_cap(new, 3000))}"
)
if out:
body += f"{_esc(_cap(str(out), 2000))}"
elif name == "bash":
cmd = inp.get("command") or inp.get("cmd") or ""
summary = f"⚡ bash {_esc(cmd[:160])}"
body = f"{_esc(_cap(str(out)))}"
elif name in ("glob", "find"):
pattern = inp.get("pattern") or inp.get("query") or ""
summary = f"🔎 {name} {_esc(pattern)}"
body = f"{_esc(_cap(str(out), 4000))}"
elif name == "grep":
pattern = inp.get("pattern") or ""
path = inp.get("path") or ""
summary = f"🔎 grep {_esc(pattern)}" + (
f" in {_esc(path)}" if path else ""
)
body = f"{_esc(_cap(str(out), 4000))}"
elif name == "todowrite":
todos = inp.get("todos") or []
summary = f"📝 todowrite ({len(todos)} items)"
body = "{_esc(_cap(str(out), 4000))}"
elif name == "webfetch":
summary = f"🌐 webfetch {_esc(inp.get('url'))}"
body = f"{_esc(_cap(str(out), 4000))}"
else:
summary = f"🔧 {_esc(name)}"
body = (
f"{_esc(_cap(json.dumps(inp, indent=2, default=str), 4000))}"
f"{_esc(_cap(str(out), 4000))}"
)
return (
"{_esc(_cap(txt, 4000))}{_esc(txt)}