defmodule AgentixComponents do
@moduledoc """
Default function components for rendering an `Agentix.Chat` conversation.
Optional sugar over the headless projection (`Agentix.Chat`): `import
AgentixComponents` and render the assigns, or run `mix agentix.gen.components` to
copy an editable version into your project. `message/1` exposes a `:bubble` slot;
the pending controls switch on `pending[id].kind` (not the executor).
## Styling
Tailwind utility classes (stock `neutral` scale + `emerald`/`red`/`amber` semantics,
`darkMode: 'class'`) in a flat, borderless style — full-width turn rows on hairline
dividers. Two small extras the host opts into:
* **grouping** — give the thread the `agentix-thread` class to collapse each turn
(a user message, or an assistant message with its tool calls) into one block with
a single header (the CSS lives in `AgentixComponents.css/0`);
* **JS hooks** — `AgentixStream` (streaming text) and `AgentixComposer`
(auto-grow + Enter-to-send), shipped at `priv/static/agentix_stream_hook.js`.
Interactive controls emit `phx-click`/`phx-submit` events for the host to wire to
`Agentix.Chat`: `"send"` (composer), `"approve"`/`"deny"` (`phx-value-id`), and
`"resolve"` (a form carrying `tool_call_id` + `answer`/`result`).
"""
use Phoenix.Component
@doc """
Renders the conversation: a grouped thread of finalized messages, the in-progress
assistant turn (running tools + streaming text), and pending controls. `messages`
accepts a `Phoenix.LiveView` stream or a list of `{dom_id, %ReqLLM.Message{}}` pairs.
"""
attr(:id, :string, default: "agentix-messages")
attr(:messages, :any, default: [])
attr(:streaming_message, :map, default: nil)
attr(:in_flight_tools, :map, default: %{})
attr(:pending, :map, default: %{})
attr(:assistant_open, :boolean,
default: false,
doc:
"true once the current assistant turn has shown a header; continuation rows then render headerless"
)
def message_list(assigns) do
~H"""
<div id={@id} class="agentix-thread" phx-update="stream">
<.message :for={{dom_id, message} <- @messages} id={dom_id} message={message} />
</div>
<.assistant_turn :if={@streaming_message || @in_flight_tools != %{}} open={@assistant_open}>
<div :if={@in_flight_tools != %{}} class="mb-3 space-y-2">
<.tool
:for={{id, t} <- @in_flight_tools}
id={id}
name={t.name}
status={Map.get(t, :status, :running)}
meta={tool_meta_label(t)}
/>
</div>
<.streaming_message :if={@streaming_message} message={@streaming_message} />
</.assistant_turn>
<.assistant_turn :for={{id, entry} <- @pending} open={@assistant_open}>
<.pending id={id} entry={entry} />
</.assistant_turn>
"""
end
# An assistant continuation row: shows the avatar + header only when the turn has
# not opened one yet (`open` false); otherwise it's a headerless continuation that
# merges with the assistant block above — so a turn never repeats the header.
attr(:open, :boolean, required: true)
slot(:inner_block, required: true)
defp assistant_turn(assigns) do
~H"""
<div
class={[
"agentix-row group flex gap-3.5",
if(@open,
do: "-mt-3 pb-5 pt-1",
else: "border-t border-neutral-200/70 py-5 dark:border-neutral-800/70"
)
]}
data-role="assistant"
>
<.avatar :if={!@open} role={:assistant} />
<div :if={@open} class="mt-0.5 h-7 w-7 shrink-0" aria-hidden="true"></div>
<div class="min-w-0 flex-1">
<.role_header :if={!@open} role={:assistant} />
{render_slot(@inner_block)}
</div>
</div>
"""
end
@doc """
Renders one finalized message as a flat row. A `:bubble` slot replaces the default
body. The `data-group` attribute (`"user"` for user messages, `"agent"` for assistant
*and* tool messages) drives turn grouping: every assistant + tool row of one turn
collapses under a single header (see `css/0`). Tool messages render as a headerless
card, never their own "Tool" header — they are part of the assistant's turn.
"""
attr(:id, :string, default: nil)
attr(:message, :map, required: true)
slot(:bubble)
def message(%{message: %{role: :tool}} = assigns) do
~H"""
<div id={@id} class="agentix-row group flex gap-3.5 py-5" data-group="agent" data-role="tool">
<div class="agentix-avatar mt-0.5 h-7 w-7 shrink-0" aria-hidden="true"></div>
<div class="min-w-0 flex-1">
<.tool
id={@message.tool_call_id}
name={tool_name(@message)}
status={tool_status(@message)}
result={message_text(@message)}
/>
</div>
</div>
"""
end
def message(assigns) do
~H"""
<div
id={@id}
class="agentix-row group flex gap-3.5 py-5"
data-group={group(@message.role)}
data-role={@message.role}
>
<.avatar role={@message.role} />
<div class="min-w-0 flex-1">
<.role_header role={@message.role} />
<div
:if={@bubble == [] and message_text(@message) != ""}
id={@id && @id <> "-text"}
phx-hook={markdown_hook(@id)}
data-md={markdown_hook(@id) && message_text(@message)}
class={[
"text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-200",
# Markdown renders to HTML (the block tags own their spacing); plain text keeps
# newlines via `whitespace-pre-wrap`. The two are mutually exclusive — applying
# both makes the inter-block newlines marked emits show as blank lines.
if(markdown_hook(@id), do: "agentix-md", else: "whitespace-pre-wrap")
]}
>{message_text(@message)}</div>
{render_slot(@bubble, @message)}
</div>
</div>
"""
end
@doc "The element the JS streaming hook writes into (text + thinking child nodes)."
attr(:message, :map, required: true)
def streaming_message(assigns) do
~H"""
<div
id={"agentix-stream-#{@message.id}"}
phx-hook="AgentixStream"
phx-update="ignore"
data-msg-id={@message.id}
><div data-agentix="thinking" hidden class="mb-3 whitespace-pre-wrap text-[13px] leading-relaxed text-neutral-500 dark:text-neutral-400"></div><div data-agentix="text" data-markdown={to_string(markdown?())} class={["caret text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-200", if(markdown?(), do: "agentix-md", else: "whitespace-pre-wrap")]}></div></div>
"""
end
@doc "A collapsed reasoning panel for a finalized turn's thinking."
attr(:label, :string, default: "Reasoning")
slot(:inner_block, required: true)
def reasoning(assigns) do
~H"""
<details class="rounded-md border border-neutral-200 bg-neutral-100/60 dark:border-neutral-800 dark:bg-neutral-900/50">
<summary class="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-[13px] text-neutral-500 dark:text-neutral-400">
<svg class="agentix-chev h-3.5 w-3.5 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 6l6 6-6 6" />
</svg>
<span class="font-medium">{@label}</span>
</summary>
<div class="border-t border-neutral-200 px-3 py-2.5 text-[13px] leading-relaxed text-neutral-500 dark:border-neutral-800 dark:text-neutral-400">
{render_slot(@inner_block)}
</div>
</details>
"""
end
@doc """
A tool call row. `status` is `:running` | `:ok` | `:error`; `meta` (a short progress
label) and `result` (the full result, shown in an expandable inspector) are optional.
"""
attr(:id, :string, required: true)
attr(:name, :string, required: true)
attr(:status, :atom, default: :running)
attr(:meta, :string, default: nil)
attr(:result, :string, default: nil)
def tool(assigns) do
~H"""
<div id={"tool-#{@id}"} class={["overflow-hidden rounded-md border", tool_border(@status)]}>
<div class="flex items-center gap-2 px-3 py-2 text-[13px]">
<.tool_icon status={@status} />
<span class={["font-mono text-[12px]", tool_text(@status)]}>{@name}</span>
<span :if={@meta} class={["text-[12px]", tool_meta(@status)]}>{@meta}</span>
<span class="ml-auto text-[12px] text-neutral-400">{tool_label(@status)}</span>
</div>
<details
:if={@result not in [nil, ""]}
class="border-t border-neutral-200/70 dark:border-neutral-800/70"
>
<summary class="cursor-pointer list-none px-3 py-1.5 text-[12px] text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
{if @status == :error, do: "Error", else: "Result"}
</summary>
<pre class="overflow-x-auto whitespace-pre-wrap break-words px-3 pb-2.5 text-[12px] leading-relaxed text-neutral-600 dark:text-neutral-300">{@result}</pre>
</details>
</div>
"""
end
@doc "Renders the pending affordance for a tool call, switching on its `kind`."
attr(:id, :string, required: true)
attr(:entry, :map, required: true)
def pending(%{entry: %{kind: :approval}} = assigns) do
~H"""
<div id={"pending-#{@id}"} class="rounded-md border border-amber-300/70 bg-amber-50 px-3.5 py-3 dark:border-amber-500/30 dark:bg-amber-500/10">
<div class="flex items-start gap-2.5">
<.icon name={:warning} class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-500" />
<div class="min-w-0 flex-1">
<div class="text-[13px] font-semibold text-amber-900 dark:text-amber-200">Permission required</div>
<div class="mt-0.5 text-[13px] text-amber-800/90 dark:text-amber-200/80">{prompt_label(@entry)}</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button type="button" phx-click="approve" phx-value-id={@id} class="rounded-md bg-neutral-900 px-3 py-1.5 text-[13px] font-medium text-neutral-50 transition hover:bg-neutral-700 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white">
Allow
</button>
<button type="button" phx-click="deny" phx-value-id={@id} class="rounded-md px-3 py-1.5 text-[13px] font-medium text-neutral-500 transition hover:bg-neutral-200/70 dark:text-neutral-400 dark:hover:bg-neutral-800/70">
Deny
</button>
</div>
</div>
</div>
</div>
"""
end
def pending(assigns) do
~H"""
<form id={"pending-#{@id}"} phx-submit="resolve" class="rounded-md border border-neutral-200 bg-neutral-100/60 px-3.5 py-3 dark:border-neutral-800 dark:bg-neutral-900/50">
<input type="hidden" name="tool_call_id" value={@id} />
<label class="text-[13px] font-medium text-neutral-700 dark:text-neutral-200">{prompt_label(@entry)}</label>
<div class="mt-2 flex gap-2">
<input type="text" name={input_name(@entry)} placeholder="Your response…" class="flex-1 rounded-md border border-neutral-300 bg-white px-2.5 py-1.5 text-[13px] text-neutral-800 placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100" />
<button type="submit" class="rounded-md bg-neutral-900 px-3 py-1.5 text-[13px] font-medium text-neutral-50 transition hover:bg-neutral-700 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white">
Send
</button>
</div>
</form>
"""
end
@doc "An inline error/warning banner."
attr(:variant, :atom, default: :error)
attr(:title, :string, required: true)
slot(:inner_block)
def error(assigns) do
~H"""
<div class={["flex items-start gap-2.5 rounded-md border px-3 py-2.5", banner_class(@variant)]}>
<.icon name={banner_icon(@variant)} class={["mt-0.5 h-4 w-4 shrink-0", banner_icon_color(@variant)]} />
<div class={["text-[13px]", banner_text(@variant)]}>
<div class="font-medium">{@title}</div>
<div :if={@inner_block != []} class="opacity-90">{render_slot(@inner_block)}</div>
</div>
</div>
"""
end
@doc """
The message composer: an auto-growing textarea with a send/stop control. Emits
`phx-submit="send"` (text field `text`); when `streaming?` it shows a Stop button
(`phx-click="cancel"`). Needs the `AgentixComposer` JS hook for Enter-to-send.
"""
attr(:streaming?, :boolean, default: false)
attr(:placeholder, :string, default: "Message the assistant…")
def composer(assigns) do
~H"""
<form phx-submit="send" class="rounded-xl border border-neutral-300 bg-white shadow-sm focus-within:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-900 dark:focus-within:border-neutral-600">
<textarea
id="agentix-composer-input"
name="text"
rows="1"
phx-hook="AgentixComposer"
placeholder={@placeholder}
class="block max-h-40 w-full resize-none bg-transparent px-3.5 py-3 text-[15px] leading-relaxed placeholder:text-neutral-400 focus:outline-none"
></textarea>
<div class="flex items-center gap-2 px-2.5 pb-2.5">
<span class="text-[12px] text-neutral-400">Enter to send · Shift+Enter for newline</span>
<button
:if={!@streaming?}
type="submit"
title="Send"
class="ml-auto grid h-8 w-8 place-items-center rounded-md bg-neutral-900 text-neutral-50 transition hover:bg-neutral-700 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white"
>
<.icon name={:send} class="h-[18px] w-[18px]" />
</button>
<button
:if={@streaming?}
type="button"
phx-click="cancel"
class="ml-auto flex h-8 items-center gap-1.5 rounded-md border border-neutral-300 bg-white px-2.5 text-[13px] font-medium text-neutral-700 transition hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<span class="h-2.5 w-2.5 rounded-[2px] bg-neutral-700 dark:bg-neutral-200"></span>Stop
</button>
</div>
</form>
"""
end
@doc "The CSS for turn grouping and the reasoning chevron. Inline once."
@spec css() :: String.t()
def css do
"""
/* A turn is a run of rows sharing a `data-group` ("user", or "agent" = assistant +
its tool calls). A hairline divider is drawn only where the group changes — i.e.
between turns — so the whole of one assistant turn (text, tools, more text) reads
as a single block with no internal lines. */
.agentix-thread > [data-group="user"] + [data-group="agent"],
.agentix-thread > [data-group="agent"] + [data-group="user"] {
border-top: 1px solid rgb(229 229 229 / 0.7);
}
.dark .agentix-thread > [data-group="user"] + [data-group="agent"],
.dark .agentix-thread > [data-group="agent"] + [data-group="user"] {
border-top-color: rgb(38 38 38 / 0.7);
}
/* Continuation rows within a turn: pull up to merge with the row above and hide the
repeated avatar + header, so a turn shows exactly one header. */
.agentix-thread > [data-group="agent"] + [data-group="agent"],
.agentix-thread > [data-group="user"] + [data-group="user"] {
padding-top: 0.25rem;
margin-top: -0.75rem;
}
.agentix-thread > [data-group="agent"] + [data-group="agent"] > .agentix-avatar,
.agentix-thread > [data-group="user"] + [data-group="user"] > .agentix-avatar {
visibility: hidden;
}
.agentix-thread > [data-group="agent"] + [data-group="agent"] .agentix-role-header,
.agentix-thread > [data-group="user"] + [data-group="user"] .agentix-role-header {
display: none;
}
details[open] .agentix-chev { transform: rotate(90deg); }
/* Markdown bodies render to HTML inside `.agentix-md`. A CSS reset (e.g. Tailwind's
preflight) strips list bullets and heading sizes, so restore readable defaults here,
scoped so they never leak into the host's own styles. */
.agentix-md > :first-child { margin-top: 0; }
.agentix-md > :last-child { margin-bottom: 0; }
.agentix-md p { margin: 0.5em 0; }
.agentix-md h1, .agentix-md h2, .agentix-md h3, .agentix-md h4 {
font-weight: 600; line-height: 1.3; margin: 1em 0 0.4em;
}
.agentix-md h1 { font-size: 1.3em; }
.agentix-md h2 { font-size: 1.15em; }
.agentix-md h3 { font-size: 1.05em; }
.agentix-md ul, .agentix-md ol { margin: 0.5em 0; padding-left: 1.5em; }
.agentix-md ul { list-style: disc; }
.agentix-md ol { list-style: decimal; }
.agentix-md li { margin: 0.2em 0; }
.agentix-md li > ul, .agentix-md li > ol { margin: 0.2em 0; }
.agentix-md pre {
margin: 0.6em 0; padding: 0.75em 0.9em; border-radius: 0.5rem;
overflow-x: auto; font-size: 0.85em; line-height: 1.45; background: rgb(0 0 0 / 0.05);
}
.agentix-md :not(pre) > code {
font-size: 0.88em; padding: 0.1em 0.35em; border-radius: 0.3rem; background: rgb(0 0 0 / 0.06);
}
.agentix-md pre code { background: none; padding: 0; font-size: 1em; }
.agentix-md blockquote {
margin: 0.6em 0; padding-left: 0.9em; border-left: 3px solid rgb(0 0 0 / 0.15); opacity: 0.85;
}
.agentix-md a { text-decoration: underline; }
.agentix-md strong { font-weight: 600; }
.agentix-md hr { margin: 1em 0; border: 0; border-top: 1px solid rgb(0 0 0 / 0.1); }
.agentix-md table { border-collapse: collapse; margin: 0.6em 0; }
.agentix-md th, .agentix-md td { border: 1px solid rgb(0 0 0 / 0.15); padding: 0.3em 0.6em; }
.dark .agentix-md pre { background: rgb(255 255 255 / 0.06); }
.dark .agentix-md :not(pre) > code { background: rgb(255 255 255 / 0.08); }
.dark .agentix-md blockquote { border-left-color: rgb(255 255 255 / 0.2); }
.dark .agentix-md hr { border-top-color: rgb(255 255 255 / 0.12); }
.dark .agentix-md th, .dark .agentix-md td { border-color: rgb(255 255 255 / 0.15); }
"""
end
## --- private ---
attr(:role, :atom, required: true)
defp role_header(assigns) do
~H"""
<div class="agentix-role-header mb-1 flex items-center gap-2">
<span class="text-[13px] font-semibold">{role_label(@role)}</span>
</div>
"""
end
attr(:role, :atom, required: true)
defp avatar(%{role: :user} = assigns) do
~H"""
<div class="agentix-avatar mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
<.icon name={:user} class="h-4 w-4" />
</div>
"""
end
defp avatar(assigns) do
~H"""
<div class="agentix-avatar mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-neutral-900 text-neutral-50 dark:bg-neutral-100 dark:text-neutral-900">
<.icon name={:star} class="h-4 w-4" />
</div>
"""
end
attr(:status, :atom, required: true)
defp tool_icon(%{status: :ok} = assigns) do
~H"""
<.icon name={:check} class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-500" />
"""
end
defp tool_icon(%{status: :error} = assigns) do
~H"""
<.icon name={:x} class="h-3.5 w-3.5 text-red-600 dark:text-red-500" />
"""
end
defp tool_icon(assigns) do
~H"""
<.icon name={:spinner} class="h-3.5 w-3.5 animate-spin text-neutral-400" />
"""
end
attr(:name, :atom, required: true)
attr(:class, :any, default: nil)
defp icon(%{name: :star} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 5.6L20 10l-5.6 2.4L12 18l-2.4-5.6L4 10l5.6-2.4z" /></svg>
"""
end
defp icon(%{name: :user} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M20 21a8 8 0 0 0-16 0" /><circle cx="12" cy="7" r="4" /></svg>
"""
end
defp icon(%{name: :spinner} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-6.2-8.5" stroke-linecap="round" /></svg>
"""
end
defp icon(%{name: :check} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M20 6L9 17l-5-5" /></svg>
"""
end
defp icon(%{name: :x} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M18 6 6 18M6 6l12 12" /></svg>
"""
end
defp icon(%{name: :warning} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
"""
end
defp icon(%{name: :send} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
"""
end
defp tool_border(:error),
do: "border-red-300/70 bg-red-50 dark:border-red-500/30 dark:bg-red-500/10"
defp tool_border(_status), do: "border-neutral-200 dark:border-neutral-800"
defp tool_text(:error), do: "text-red-700 dark:text-red-300"
defp tool_text(_status), do: "text-neutral-700 dark:text-neutral-200"
defp tool_meta(:error), do: "text-red-600/80 dark:text-red-400/80"
defp tool_meta(_status), do: "text-neutral-400"
defp tool_label(:running), do: "running"
defp tool_label(:ok), do: "done"
defp tool_label(:error), do: "error"
defp banner_class(:warning),
do: "border-amber-300/70 bg-amber-50 dark:border-amber-500/30 dark:bg-amber-500/10"
defp banner_class(_variant),
do: "border-red-300/70 bg-red-50 dark:border-red-500/30 dark:bg-red-500/10"
# One glyph for both variants; the colour (see `banner_icon_color/1`) distinguishes them.
defp banner_icon(_variant), do: :warning
defp banner_icon_color(:warning), do: "text-amber-600 dark:text-amber-500"
defp banner_icon_color(_variant), do: "text-red-600 dark:text-red-500"
defp banner_text(:warning), do: "text-amber-900 dark:text-amber-200"
defp banner_text(_variant), do: "text-red-800 dark:text-red-200"
defp role_label(:user), do: "You"
defp role_label(:assistant), do: "Assistant"
defp role_label(role), do: role |> to_string() |> String.capitalize()
defp message_text(%{content: parts}) when is_list(parts),
do: parts |> Enum.map(&Map.get(&1, :text)) |> Enum.reject(&is_nil/1) |> Enum.join("")
defp message_text(_message), do: ""
# Markdown rendering is on by default; a host opts out with
# `config :agentix, render_markdown: false`. The markdown engine is the host's (wired
# into the JS hook via `configureMarkdown/1`) — see `agentix_stream_hook.js`.
defp markdown?, do: Application.get_env(:agentix, :render_markdown, true)
defp markdown_hook(id) when is_binary(id) do
if markdown?(), do: "AgentixMarkdown"
end
defp markdown_hook(_id), do: nil
# Turn grouping: user messages are their own group; assistant and tool messages share
# the "agent" group so a turn's text + tool rows collapse under one header.
defp group(:user), do: "user"
defp group(_role), do: "agent"
defp tool_name(%{metadata: %{"tool_name" => name}}) when is_binary(name), do: name
defp tool_name(_message), do: "tool"
# A live tool's status line: a binary `:tool_progress` payload if one has arrived, else
# any explicit `:meta`. Non-binary progress is left to a host's own renderer.
defp tool_meta_label(%{progress: progress}) when is_binary(progress), do: progress
defp tool_meta_label(tool), do: Map.get(tool, :meta)
defp tool_status(%{metadata: %{"tool_status" => "error"}}), do: :error
defp tool_status(_message), do: :ok
defp prompt_label(%{kind: :approval}), do: "Approval required to continue."
defp prompt_label(%{kind: :elicitation}), do: "The assistant needs more information."
defp prompt_label(%{kind: :client_exec}), do: "A client action is requested."
defp prompt_label(_entry), do: "Pending"
defp input_name(%{kind: :client_exec}), do: "result"
defp input_name(_entry), do: "answer"
end