Packages

Elixir/OTP runtime for supervised coding-agent sessions with ACP, Subagents, Workflows, and replayable evidence.

Current section

Files

Jump to
pixir lib pixir renderer.ex
Raw

lib/pixir/renderer.ex

defmodule Pixir.Renderer do
@moduledoc """
The first thin front-end over the event bus (ADR 0004, decision D-05): a stdout
subscriber that pattern-matches on `event.type`. It validates the seam — the core
needs no changes for a new front-end.
Channel discipline (ADR 0005):
* **stdout** — the model's answer: streamed `text_delta`s and the final newline.
* **stderr** — activity and diagnostics: tool calls/results, reasoning, status.
`render/1` is the pure mapping from an Event to writes (`[{:stdout | :stderr, io}]`),
so it is unit-testable. `consume_until_done/1` runs the receive loop, performing the
writes until a terminal `status` (`"done"` / `"error"`) arrives.
"""
@idle_timeout 120_000
@doc "Map an Event to a list of `{:stdout | :stderr, iodata}` writes."
@spec render(map()) :: [{:stdout | :stderr, iodata()}]
def render(%{type: :text_delta, data: %{"chunk" => chunk}}), do: [{:stdout, chunk}]
def render(%{type: :reasoning_delta, data: %{"chunk" => chunk}}), do: [{:stderr, chunk}]
def render(%{type: :assistant_message}), do: [{:stdout, "\n"}]
def render(%{type: :tool_call, data: %{"name" => name, "args" => args}}),
do: [{:stderr, "\n#{name} #{compact(args)}\n"}]
def render(%{type: :tool_result, data: data}), do: [{:stderr, tool_result_line(data)}]
def render(%{type: :status, data: %{"status" => status}})
when status in ["error", "interrupted"],
do: [{:stderr, "[#{status}]\n"}]
# Context-pressure output (ADR 0020) is diagnostics for the human, never the
# model: stderr only, from an ephemeral event that the Log cannot contain.
# Routine snapshots feed presenters such as T3; terminal CLI should stay quiet
# until the event is an advisory/warning/recovery notice.
def render(%{type: :context_pressure, data: %{"presentation" => "snapshot"}}), do: []
def render(%{type: :context_pressure, data: %{"tier" => "advisory"} = data}),
do: [{:stderr, "[context #{percent(data)} of the #{data["model"]} window]\n"}]
def render(%{type: :context_pressure, data: %{"tier" => tier} = data})
when tier in ["warning", "critical"],
do: [{:stderr, context_warning(data)}]
def render(%{type: :context_pressure, data: %{"tier" => "recovery", "message" => message}})
when is_binary(message),
do: [{:stderr, "[context] #{message}\n"}]
def render(_event), do: []
@doc """
Receive and render Events for the calling (already-subscribed) process until a
terminal status. Returns `:ok` or `:timeout` after `:idle_timeout` ms of silence.
"""
@spec consume_until_done(keyword()) :: :ok | :timeout
def consume_until_done(opts \\ []) do
timeout = Keyword.get(opts, :idle_timeout, @idle_timeout)
receive do
{:pixir_event, event} ->
Enum.each(render(event), &write/1)
if terminal?(event), do: :ok, else: consume_until_done(opts)
after
timeout -> :timeout
end
end
# ── internals ─────────────────────────────────────────────────────────────
defp terminal?(%{type: :status, data: %{"status" => status}})
when status in ["done", "error", "interrupted"],
do: true
defp terminal?(_event), do: false
@doc "Perform a single `{:stdout | :stderr, iodata}` write (used by front-ends)."
@spec write({:stdout | :stderr, iodata()}) :: :ok
def write({:stdout, io}), do: IO.write(io)
def write({:stderr, io}), do: IO.write(:stderr, io)
defp context_warning(data) do
header =
"[context #{percent(data)} of the #{data["model"]} window] " <>
"WARNING: approaching the model context limit.\n"
actions =
data
|> Map.get("next_actions", [])
|> Enum.filter(&is_map/1)
|> Enum.map_join("", fn action -> " next: #{action["command"]}\n" end)
header <> actions
end
defp percent(%{"ratio" => ratio}) when is_number(ratio), do: "#{round(ratio * 100)}%"
defp percent(_data), do: "?%"
defp tool_result_line(%{"ok" => true}), do: " ok\n"
defp tool_result_line(%{"ok" => false, "error" => %{"kind" => kind}}), do: " error (#{kind})\n"
defp tool_result_line(%{"dry_run" => true}), do: " (dry-run)\n"
defp tool_result_line(_), do: " done\n"
defp compact(args) when is_map(args) do
args
|> Enum.map_join(" ", fn {k, v} -> "#{k}=#{truncate_arg(v)}" end)
end
defp compact(args), do: inspect(args)
defp truncate_arg(v) do
s = if is_binary(v), do: v, else: inspect(v)
if String.length(s) > 60, do: String.slice(s, 0, 57) <> "...", else: s
end
end