defmodule OpenAgentsWeb.AI.Evidence do
@moduledoc """
Evidence and artifacts: what a model produced, and where it came from.
Ported from Vercel's AI Elements (MIT), specifically `code-block.tsx`,
`snippet.tsx`, `terminal.tsx`, `sources.tsx`, `inline-citation.tsx`,
`context.tsx`, `artifact.tsx`, `confirmation.tsx`, `question.tsx`, and
`image.tsx`. The Tailwind carried over as written; the React did not. Every
source component is a client component built on Radix — hover cards,
collapsibles, carousels, tooltips — and none of that survives the move to
HEEx. What survives is the information design: what a code block's chrome
holds, that a citation is a hostname chip that opens onto the sources behind
it, that a token meter states a percentage twice (as a number and as an arc),
and that a confirmation shows its decision after the fact rather than
vanishing.
## Departures from the source, and why
* **No syntax highlighting.** AI Elements tokenizes with Shiki in the
browser and paints one `span` per token. Shiki is a JavaScript
highlighter and a second rendering engine; `code_block/1` keeps the
chrome, the line numbers, and the copy affordance, and renders the code
as text. Highlighting, if it is ever wanted, belongs on the server.
* **No ANSI parsing.** `terminal/1`'s source pipes output through
`ansi-to-react`. Escape sequences arrive here as literal text. Strip them
before passing `output`, or add a server-side parser later.
* **No carousel.** `inline_citation/1`'s source paginates its sources with
Embla. The sources are listed instead, which is the static fallback: the
card is already scoped to one citation, so the count is small.
* **Hover cards and collapsibles are CSS and markup.** `sources/1` is a
`details`/`summary` pair. `inline_citation/1` and `context/1` reveal
their card on hover and on focus within, so a keyboard reaches them.
Neither needs a script.
* **The context ring is a conic gradient, not a drawn arc.** The source
draws two circles and animates `stroke-dashoffset`. Inline SVG is not
allowed in a product surface here (`docs/ICONS.md`), so the ring is a
masked `conic-gradient` in an inline style. It reads identically and
costs no glyph.
* **`context/1` clamps its bar and flags the overrun.** The source lets a
progress bar past 100% render however the browser feels. A meter that
silently pins at full hides the one state worth seeing, so an over-budget
meter carries `data-over-budget="true"` and turns to the danger tone.
* **Costs are attributes, not computed.** The source prices usage with
`tokenlens`. There is no pricing table here, so a caller that knows the
price passes it.
* **`question/1` selects with real form controls.** The source toggles
React state on buttons carrying `aria-checked`. Radio and checkbox inputs
styled as chips give the same look with real keyboard behaviour, real
form submission, and no client state. Its submit control cannot disable
itself until a choice is made — that was React state — so the server
validates instead.
## Utilities that had to change
The Tailwind is the source's, class for class, with three exceptions. Two are
name collisions: `assets/css/app.css` defines `--accent` as the brand indigo
rather than shadcn's quiet hover surface, and `--primary` as the same indigo
rather than the near-foreground ink, so `bg-accent` became `bg-muted` in
`inline_citation/1` and `text-primary` became `text-foreground` in
`sources/1`. The third is `not-prose` on `sources/1`, which escapes Tailwind
Typography; that plugin is not installed here, so the class resolved to
nothing and was dropped.
## Images and Markdown
`image/1` renders its own `img` element from attributes. It has to:
`OpenAgents.Markdown.to_html/2` sanitizes to an allowlist that has no `img`
in it, so an image written into Markdown is dropped before it reaches the
page. Model-generated images arrive as attributes and are rendered here.
"""
use Phoenix.Component
alias OpenAgentsWeb.UI
@byte_units ~w(K M B)
@doc """
A block of code with chrome: a filename, a language, actions, and the code.
The code is rendered as text. Line numbers, when asked for, come from a CSS
counter incremented once per line, so no number is ever selected with the
code it labels.
The `pre` carries `phx-no-curly-interpolation`, which is what lets a snippet
containing `{` and `}` survive HEEx unescaped.
"""
attr :id, :string, required: true
attr :code, :string, required: true
attr :language, :string, default: nil, doc: "recorded on the container as `data-language`"
attr :filename, :string, default: nil
attr :show_line_numbers, :boolean, default: false
attr :copy, :boolean, default: true, doc: "render the copy affordance in the header"
attr :class, :any, default: nil
attr :rest, :global
slot :actions, doc: "controls at the trailing edge of the header"
def code_block(assigns) do
assigns = assign(assigns, :lines, String.split(assigns.code, "\n"))
~H"""
<div
id={@id}
class={[
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
@class
]}
data-language={@language}
{@rest}
>
<div
:if={@filename || @language || @copy || @actions != []}
class="flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs"
>
<div class="flex items-center gap-2">
<span :if={@filename} class="font-mono">{@filename}</span>
<span :if={is_nil(@filename) && @language} class="font-mono">{@language}</span>
</div>
<div class="-my-1 -mr-1 flex items-center gap-2">
{render_slot(@actions)}
<UI.copy_button :if={@copy} id={@id <> "-copy"} text={@code} label="Copy" />
</div>
</div>
<div class="relative overflow-auto">
<pre class="m-0 p-4 text-sm" phx-no-curly-interpolation><code class={["font-mono text-sm", @show_line_numbers && "[counter-reset:line]"]}><span :for={line <- @lines} class={@show_line_numbers && line_number_classes() || "block"}><%= line %></span></code></pre>
</div>
</div>
"""
end
# The counter, its increment, and the gutter it prints into. Kept out of the
# template because it is nine variants of one idea and reads as noise inline.
defp line_number_classes do
"block before:mr-4 before:inline-block before:w-8 before:[counter-increment:line] " <>
"before:content-[counter(line)] before:select-none before:text-right " <>
"before:font-mono before:text-muted-foreground/50"
end
@doc """
One command, in a field you copy rather than retype.
The input is read-only and holds the whole command, so selecting it selects
the command and nothing else. The prefix — a shell sigil, a package manager —
sits outside the field for the same reason: it is chrome, not text you want
on the clipboard.
The source composes this from shadcn's `InputGroup`. That component is not
vendored here, so the group is assembled from the same utilities.
"""
attr :id, :string, required: true
attr :code, :string, required: true
attr :prefix, :string, default: nil, doc: "a sigil shown before the command, such as `$`"
attr :copy, :boolean, default: true
attr :label, :string, default: "Command", doc: "the accessible name of the read-only field"
attr :class, :any, default: nil
attr :rest, :global
def snippet(assigns) do
~H"""
<div
id={@id}
class={[
"flex w-full items-center gap-1 rounded-md border bg-background py-1 pr-1 pl-2 font-mono text-sm",
@class
]}
{@rest}
>
<span :if={@prefix} class="pl-2 font-normal text-muted-foreground">{@prefix}</span>
<%!-- A bare input rather than `UI.input/1`. The group is the control here
and carries the border, radius, and fill; `.input` carries its own
set, so composing them would draw a field inside a field. --%>
<input
id={@id <> "-input"}
class="min-w-0 flex-1 border-0 bg-transparent px-2 py-1 text-foreground outline-none"
readonly
aria-label={@label}
value={@code}
/>
<UI.copy_button :if={@copy} id={@id <> "-copy"} text={@code} label="Copy" />
</div>
"""
end
@doc """
A terminal transcript: a dark well holding command output.
Fixed dark in both themes, as in the source. A terminal that inverts with the
page stops reading as a terminal, and the output inside it was written for a
dark ground.
`output` is rendered verbatim. ANSI escape sequences are not parsed — see the
module documentation.
"""
attr :id, :string, required: true
attr :output, :string, default: ""
attr :title, :string, default: "Terminal"
attr :streaming, :boolean, default: false, doc: "show the caret and the running status"
attr :status, :string, default: nil, doc: "shown beside the actions while streaming"
attr :copy, :boolean, default: true
attr :class, :any, default: nil
attr :rest, :global
slot :actions, doc: "controls beside the copy affordance"
slot :inner_block, doc: "replaces the rendered output, for composing lines by hand"
def terminal(assigns) do
~H"""
<div
id={@id}
class={[
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
@class
]}
{@rest}
>
<div class="flex items-center justify-between border-zinc-800 border-b px-4 py-2">
<div class="flex items-center gap-2 text-sm text-zinc-400">
<UI.icon name="terminal" class="size-4" />{@title}
</div>
<div class="flex items-center gap-1">
<span :if={@streaming && @status} class="flex items-center gap-2 text-xs text-zinc-400">
{@status}
</span>
<div class="flex items-center gap-1">
{render_slot(@actions)}
<UI.copy_button :if={@copy} id={@id <> "-copy"} text={@output} label="Copy" />
</div>
</div>
</div>
<div class="max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed">
<%= if @inner_block == [] do %>
<pre class="whitespace-pre-wrap break-words" phx-no-curly-interpolation><%= @output %><span :if={@streaming} class="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100"></span></pre>
<% else %>
{render_slot(@inner_block)}
<% end %>
</div>
</div>
"""
end
@doc """
One line of a terminal transcript: a prompt sigil and the command after it.
For composing a transcript by hand inside `terminal/1`'s inner block, where
the commands and their output are separate values rather than one string.
"""
attr :prompt, :string, default: "$"
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def terminal_line(assigns) do
~H"""
<div class={["flex gap-2 whitespace-pre-wrap break-words", @class]} {@rest}>
<span aria-hidden="true" class="select-none text-zinc-500">{@prompt}</span>
<span class="min-w-0 flex-1">{render_slot(@inner_block)}</span>
</div>
"""
end
@doc """
The sources a message was drawn from, folded away until asked for.
A `details`/`summary` pair rather than a collapsible built from script: the
browser already knows how to open and close a disclosure, announce its state,
and reach it from the keyboard.
The source carries `not-prose` to escape Tailwind Typography. That plugin is
not installed here, so the class would resolve to nothing and is dropped.
"""
attr :id, :string, required: true
attr :count, :integer, required: true
attr :open, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
slot :trigger, doc: "replaces the default summary line"
slot :inner_block, required: true, doc: "the sources, normally `source/1` calls"
def sources(assigns) do
~H"""
<%!-- `text-foreground`, not the source's `text-primary`: `--primary` here is
the brand indigo (app.css), where shadcn's is the near-foreground ink.
Left as written, the whole disclosure came out indigo. --%>
<details id={@id} open={@open} class={["group mb-4 text-foreground text-xs", @class]} {@rest}>
<summary class="flex cursor-pointer list-none items-center gap-2">
<%= if @trigger == [] do %>
<p class="font-medium">Used {@count} sources</p>
<UI.icon name="chevron-down" class="h-4 w-4 transition-transform group-open:rotate-180" />
<% else %>
{render_slot(@trigger)}
<% end %>
</summary>
<div class="mt-3 flex w-fit flex-col gap-2">
{render_slot(@inner_block)}
</div>
</details>
"""
end
@doc "One source behind a message: a glyph and the title, linking out."
attr :href, :string, required: true
attr :title, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block
def source(assigns) do
~H"""
<a
href={@href}
class={["flex items-center gap-2", @class]}
rel="noreferrer"
target="_blank"
{@rest}
>
<%= if @inner_block == [] do %>
<UI.icon name="book" class="h-4 w-4" />
<span class="block font-medium">{@title || @href}</span>
<% else %>
{render_slot(@inner_block)}
<% end %>
</a>
"""
end
@doc """
A run of cited text, with the sources behind it one hover away.
The chip names the first source by hostname and counts the rest, which is the
useful summary: a reader scanning a paragraph wants to know *whose* claim it
is before deciding to open anything.
The card opens on hover and on focus within, so it is reachable without a
pointer. The source paginates with a carousel; the sources are listed here
instead.
"""
attr :id, :string, required: true
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true, doc: "the cited text"
slot :source, doc: "one source behind the citation" do
attr :url, :string, required: true
attr :title, :string
attr :description, :string
end
def inline_citation(assigns) do
~H"""
<span id={@id} class={["group relative inline items-center gap-1", @class]} {@rest}>
<%!-- `bg-muted`, not the source's `bg-accent`: app.css redefines `--accent` as
the brand indigo rather than shadcn's quiet hover surface, and a run of
prose highlighted in indigo on hover reads as a selection, not a hint. --%>
<span class="transition-colors group-hover:bg-muted">{render_slot(@inner_block)}</span>
<%!-- A button wrapping the badge, not a badge that looks clickable. The
card opens on hover and on focus within, and only a real control
takes focus, so the keyboard reaches what the pointer does. --%>
<button type="button" aria-describedby={@id <> "-card"} class="align-baseline">
<UI.badge variant={:dim} class="ml-1 rounded-full">{citation_label(@source)}</UI.badge>
</button>
<span
id={@id <> "-card"}
role="note"
class={[
"invisible absolute top-full left-0 z-50 mt-1 w-80 rounded-md border bg-popover p-0",
"text-popover-foreground opacity-0 shadow-md transition-opacity",
"group-hover:visible group-hover:opacity-100",
"group-focus-within:visible group-focus-within:opacity-100"
]}
>
<span class="flex items-center justify-end gap-2 rounded-t-md bg-secondary px-3 py-2 text-muted-foreground text-xs">
{length(@source)} {(length(@source) == 1 && "source") || "sources"}
</span>
<span class="block divide-y">
<span :for={source <- @source} class="block w-full space-y-2 p-4">
<.inline_citation_source
title={source[:title]}
url={source[:url]}
description={source[:description]}
>
<%!-- The slot's three attributes already say everything a source
has, so `<:source url=... title=... />` is the ordinary call
and carries no content. `render_slot/1` on an entry whose
`inner_block` is nil raises, so ask before rendering. --%>
{source[:inner_block] && render_slot(source)}
</.inline_citation_source>
</span>
</span>
</span>
</span>
"""
end
@doc "The title, address, and summary of one cited source."
attr :title, :string, default: nil
attr :url, :string, default: nil
attr :description, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block
def inline_citation_source(assigns) do
~H"""
<span class={["block space-y-1", @class]} {@rest}>
<span :if={@title} class="block truncate font-medium text-sm leading-tight">{@title}</span>
<span :if={@url} class="block truncate break-all text-muted-foreground text-xs">{@url}</span>
<span :if={@description} class="block text-muted-foreground text-sm leading-relaxed">
{@description}
</span>
{render_slot(@inner_block)}
</span>
"""
end
@doc "A quotation lifted from a cited source."
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def inline_citation_quote(assigns) do
~H"""
<span
class={["block border-muted border-l-2 pl-3 text-muted-foreground text-sm italic", @class]}
{@rest}
>
{render_slot(@inner_block)}
</span>
"""
end
@doc """
How much of a model's context window a conversation has spent.
The percentage is stated twice — as a number and as an arc — because the arc
is the thing read at a glance and the number is the thing acted on. Hovering
or focusing the control opens the breakdown: the split by kind, and what it
cost.
Past the window, the bar pins at full and the control carries
`data-over-budget="true"`, so the one state worth seeing does not look
identical to a full but legal context.
"""
attr :id, :string, required: true
attr :used_tokens, :integer, required: true
attr :max_tokens, :integer, required: true
attr :input_tokens, :integer, default: nil
attr :output_tokens, :integer, default: nil
attr :reasoning_tokens, :integer, default: nil
attr :cached_tokens, :integer, default: nil
attr :input_cost, :float, default: nil, doc: "US dollars, since there is no pricing table here"
attr :output_cost, :float, default: nil
attr :reasoning_cost, :float, default: nil
attr :cached_cost, :float, default: nil
attr :total_cost, :float, default: nil
attr :class, :any, default: nil
attr :rest, :global
def context(assigns) do
fraction = safe_fraction(assigns.used_tokens, assigns.max_tokens)
assigns =
assign(assigns,
fraction: fraction,
bar_percent: min(fraction * 100, 100.0),
over_budget?: fraction > 1.0
)
~H"""
<div
id={@id}
class={["group relative inline-block", @class]}
data-over-budget={to_string(@over_budget?)}
{@rest}
>
<UI.button variant={:ghost} size={:sm} aria-describedby={@id <> "-detail"}>
<span class={["font-medium", (@over_budget? && "text-danger") || "text-muted-foreground"]}>
{percent_text(@fraction)}
</span>
<span
role="img"
aria-label="Model context usage"
class="inline-block size-5 shrink-0 rounded-full"
style={ring_style(@bar_percent)}
></span>
</UI.button>
<div
id={@id <> "-detail"}
role="note"
class={[
"invisible absolute top-full right-0 z-50 mt-1 min-w-60 divide-y overflow-hidden",
"rounded-md border bg-popover text-popover-foreground opacity-0 shadow-md",
"transition-opacity group-hover:visible group-hover:opacity-100",
"group-focus-within:visible group-focus-within:opacity-100"
]}
>
<div class="w-full space-y-2 p-3">
<div class="flex items-center justify-between gap-3 text-xs">
<p>{percent_text(@fraction)}</p>
<p class="font-mono text-muted-foreground">
{compact_number(@used_tokens)} / {compact_number(@max_tokens)}
</p>
</div>
<div
class="h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Context used"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={round(@bar_percent)}
>
<div
class={["h-full rounded-full", (@over_budget? && "bg-danger") || "bg-primary"]}
style={"width: #{@bar_percent}%"}
>
</div>
</div>
</div>
<div
:if={@input_tokens || @output_tokens || @reasoning_tokens || @cached_tokens}
class="w-full space-y-1 p-3"
>
<.usage_row :if={@input_tokens} label="Input" tokens={@input_tokens} cost={@input_cost} />
<.usage_row :if={@output_tokens} label="Output" tokens={@output_tokens} cost={@output_cost} />
<.usage_row
:if={@reasoning_tokens}
label="Reasoning"
tokens={@reasoning_tokens}
cost={@reasoning_cost}
/>
<.usage_row :if={@cached_tokens} label="Cache" tokens={@cached_tokens} cost={@cached_cost} />
</div>
<div
:if={@total_cost}
class="flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs"
>
<span class="text-muted-foreground">Total cost</span>
<span>{currency(@total_cost)}</span>
</div>
</div>
</div>
"""
end
attr :label, :string, required: true
attr :tokens, :integer, required: true
attr :cost, :float, default: nil
defp usage_row(assigns) do
~H"""
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{@label}</span>
<span>
{compact_number(@tokens)}
<span :if={@cost} class="ml-2 text-muted-foreground">
• {currency(@cost)}
</span>
</span>
</div>
"""
end
@doc """
A named thing the model produced, in a frame with its own actions.
The frame is the point: an artifact is a deliverable, not a paragraph, so it
gets a header that names it and a body that scrolls on its own rather than
extending the transcript.
"""
attr :id, :string, required: true
attr :title, :string, required: true
attr :description, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :actions, doc: "controls at the trailing edge of the header, normally `artifact_action/1`"
slot :inner_block, required: true
def artifact(assigns) do
~H"""
<div
id={@id}
class={["flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm", @class]}
{@rest}
>
<div class="flex items-center justify-between border-b bg-muted/50 px-4 py-3">
<div class="min-w-0">
<p class="font-medium text-foreground text-sm">{@title}</p>
<p :if={@description} class="text-muted-foreground text-sm">{@description}</p>
</div>
<div :if={@actions != []} class="flex items-center gap-1">
{render_slot(@actions)}
</div>
</div>
<div class="flex-1 overflow-auto p-4">
{render_slot(@inner_block)}
</div>
</div>
"""
end
@doc """
One icon-only control in an artifact's header.
The source wraps each of these in a Radix tooltip. There is no tooltip
primitive here, so the hint is the native `title` and the accessible name is
`label` — which the control needs regardless, tooltip or not.
"""
attr :icon, :string, required: true, doc: "a name from the governed icon set"
attr :label, :string, required: true, doc: "the accessible name of the control"
attr :tooltip, :string, default: nil, doc: "hover hint; falls back to `label`"
attr :class, :any, default: nil
attr :rest, :global,
include: ~w(disabled form name value href navigate patch phx-click phx-value-id)
def artifact_action(assigns) do
~H"""
<UI.button
variant={:ghost}
size={:sm}
class={["size-8 p-0 text-muted-foreground hover:text-foreground", @class]}
title={@tooltip || @label}
aria-label={@label}
{@rest}
>
<UI.icon name={@icon} class="size-4" />
</UI.button>
"""
end
@doc """
A tool asking permission, and the record of what was decided.
The decided states are not a smaller version of the request: the controls go
away and the outcome takes their place, so a transcript scrolled back through
still says what was allowed.
"""
attr :id, :string, required: true
attr :state, :atom,
values: [:requested, :approved, :denied],
required: true,
doc: "`:requested` shows the controls; the other two show the outcome"
attr :title, :string, default: nil
attr :reason, :string, default: nil, doc: "why the decision went the way it did"
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, doc: "what is being asked for; replaces `title`"
slot :actions, doc: "the approve and deny controls, normally `confirmation_action/1`"
def confirmation(assigns) do
~H"""
<UI.alert
id={@id}
variant={confirmation_variant(@state)}
appearance={:notice}
class={@class}
data-state={@state}
{@rest}
>
<%!-- One flex column inside the alert, not on it. `UI.alert/1` wraps its
inner block in a section of its own, so a column declared on the
alert would lay out the alert's parts rather than these. --%>
<div class="flex flex-col gap-2">
<span class="inline">
<%= if @inner_block == [] do %>
{@title}
<% else %>
{render_slot(@inner_block)}
<% end %>
</span>
<span :if={@state != :requested} class="inline text-muted-foreground text-sm">
{confirmation_outcome(@state)}<span :if={@reason}>: {@reason}</span>
</span>
<span
:if={@state == :requested && @actions != []}
class="flex items-center justify-end gap-2 self-end"
>
{render_slot(@actions)}
</span>
</div>
</UI.alert>
"""
end
@doc "One control on a confirmation: approve, or deny."
attr :variant, :atom,
values: [:primary, :secondary, :outline, :ghost, :destructive, :chip, :notched, :link],
default: :secondary
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled form name value phx-click phx-value-id)
slot :inner_block, required: true
def confirmation_action(assigns) do
~H"""
<UI.button variant={@variant} class={["h-8 px-3 text-sm", @class]} {@rest}>
{render_slot(@inner_block)}
</UI.button>
"""
end
@doc """
A question back to the reader: a set of choices, and room to say something
else.
Both halves matter. A model that can only offer choices asks the wrong
question sooner or later, and a model that only offers a text box makes the
reader do the work it already did.
Choices are radio inputs in single mode and checkboxes in multiple mode, so
selection is the browser's job and the answer arrives as ordinary form
parameters.
"""
attr :id, :string, required: true
attr :prompt, :string, required: true
attr :description, :string, default: nil
attr :name, :string, default: "question", doc: "the parameter the choices submit under"
attr :selection_mode, :atom,
values: [:single, :multiple],
default: :single
attr :selected, :list, default: [], doc: "the values that arrive already chosen"
attr :text, :string, default: nil, doc: "the freeform response that arrives already written"
attr :text_label, :string, default: "Anything else?"
attr :placeholder, :string, default: nil
attr :disabled, :boolean, default: false
attr :submit_label, :string, default: "Submit"
attr :class, :any, default: nil
attr :rest, :global, include: ~w(phx-submit phx-change phx-target method action)
slot :option, doc: "one choice" do
attr :value, :string, required: true
end
def question(assigns) do
assigns =
assign(assigns,
input_type: (assigns.selection_mode == :single && "radio") || "checkbox",
field_name: (assigns.selection_mode == :single && assigns.name) || assigns.name <> "[]"
)
~H"""
<form id={@id} class={["space-y-4 rounded-lg border bg-background p-4", @class]} {@rest}>
<p class="font-medium text-sm">{@prompt}</p>
<p :if={@description} class="text-muted-foreground text-sm">{@description}</p>
<fieldset :if={@option != []} class="flex flex-wrap gap-2" disabled={@disabled}>
<legend class="sr-only">{@prompt}</legend>
<label
:for={{option, index} <- Enum.with_index(@option)}
for={"#{@id}-option-#{index}"}
class={
[
"btn h-auto cursor-pointer whitespace-normal",
"has-[:checked]:bg-primary has-[:checked]:text-primary-foreground",
# The control the reader operates is the chip; the input inside it is
# visually hidden, so the focus ring has to be drawn by the chip or
# keyboard selection happens with nothing on screen moving.
"has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2",
"has-[:focus-visible]:outline-ring"
]
}
data-variant="outline"
data-size="sm"
>
<input
type={@input_type}
id={"#{@id}-option-#{index}"}
name={@field_name}
value={option.value}
checked={option.value in @selected}
class="sr-only"
/>
{render_slot(option)}
</label>
</fieldset>
<div class="space-y-1">
<UI.label for={@id <> "-text"}>{@text_label}</UI.label>
<UI.textarea
id={@id <> "-text"}
name={@name <> "_text"}
value={@text}
class="min-h-20"
placeholder={@placeholder}
disabled={@disabled}
/>
</div>
<div class="flex items-center justify-end gap-2">
<UI.button type="submit" disabled={@disabled}>{@submit_label}</UI.button>
</div>
</form>
"""
end
@doc """
A generated image, in a frame that does not let it push the page around.
Takes either a `src` or the `base64` and `media_type` a model returns, which
become a data URI. `alt` is required: an image with no alternative text is
the whole message to a reader who cannot see it, and a generated image is
exactly the case where nothing nearby says what it shows.
Markdown cannot carry this. `OpenAgents.Markdown.to_html/2` sanitizes to an
allowlist with no `img` in it, so an image written into Markdown is dropped.
"""
attr :id, :string, required: true
attr :alt, :string, required: true
attr :src, :string, default: nil
attr :base64, :string, default: nil
attr :media_type, :string, default: nil, doc: "an IANA media type, such as `image/png`"
attr :class, :any, default: nil
attr :rest, :global
def image(assigns) do
~H"""
<img
id={@id}
src={image_source(@src, @base64, @media_type)}
alt={@alt}
class={["h-auto max-w-full overflow-hidden rounded-md", @class]}
{@rest}
/>
"""
end
# ── formatting ────────────────────────────────────────────────────────────
defp image_source(nil, base64, media_type) when is_binary(base64) and is_binary(media_type),
do: "data:#{media_type};base64,#{base64}"
defp image_source(src, _base64, _media_type), do: src
defp confirmation_variant(:requested), do: :warning
defp confirmation_variant(:approved), do: :success
defp confirmation_variant(:denied), do: :danger
defp confirmation_outcome(:approved), do: "Approved"
defp confirmation_outcome(:denied), do: "Denied"
defp citation_label([]), do: "unknown"
defp citation_label([first | rest]) do
host = URI.parse(first[:url] || "").host || "unknown"
if rest == [], do: host, else: "#{host} +#{length(rest)}"
end
defp safe_fraction(_used, max) when max in [nil, 0], do: 0.0
defp safe_fraction(used, max), do: used / max
# A masked conic gradient. Two stops draw the arc against a quarter-strength
# track, and the radial mask cuts the middle out, which is what turns a pie
# into a ring. `currentColor` keeps it in the colour of the text beside it,
# exactly as the drawn version did.
defp ring_style(percent) do
"background: conic-gradient(currentColor #{percent}%, " <>
"color-mix(in oklab, currentColor 25%, transparent) 0); " <>
"mask: radial-gradient(closest-side, transparent 70%, black 72%); " <>
"-webkit-mask: radial-gradient(closest-side, transparent 70%, black 72%);"
end
defp percent_text(fraction) do
rounded = Float.round(fraction * 100, 1)
if rounded == Float.round(rounded, 0) do
"#{trunc(rounded)}%"
else
"#{rounded}%"
end
end
defp currency(amount), do: "$#{:erlang.float_to_binary(amount / 1, decimals: 2)}"
# `Intl.NumberFormat(..., {notation: "compact"})` in Elixir: two significant
# digits below ten, whole numbers above it, and no trailing zero.
defp compact_number(value) when value < 1000, do: Integer.to_string(value)
defp compact_number(value), do: compact_number(value / 1000, @byte_units)
defp compact_number(value, [unit]), do: "#{compact_mantissa(value)}#{unit}"
defp compact_number(value, [unit | rest]) do
if value < 1000,
do: "#{compact_mantissa(value)}#{unit}",
else: compact_number(value / 1000, rest)
end
defp compact_mantissa(value) when value < 10 do
rounded = Float.round(value, 1)
if rounded == Float.round(rounded, 0),
do: Integer.to_string(trunc(rounded)),
else: "#{rounded}"
end
defp compact_mantissa(value), do: Integer.to_string(round(value))
end