diff --git a/guides/user/UG-0003-widget-types-properties-and-signals.md b/guides/user/UG-0003-widget-types-properties-and-signals.md
index 4382ea76..ef225fc1 100644
--- a/guides/user/UG-0003-widget-types-properties-and-signals.md
+++ b/guides/user/UG-0003-widget-types-properties-and-signals.md
@@ -98,7 +98,7 @@ authoring boundaries and normalize before renderer-facing output.
|---|---|---|
| Content, identity, and disclosure | `inline_rich_text_heading`, `disclosure`, `kicker`, `avatar`, `presence_dot` | none |
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `collection_picker`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
-| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card` | none |
+| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card`, `tool_call_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview`, `propose_new_doc_card` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
@@ -161,6 +161,13 @@ canonical `accept`, `reject`, `preview`, `body_toggled`, and
`conversation_seed_toggled` interactions for operator actions. Keep LiveView
event fields, route helpers, and product-specific copy in the host layer.
+`tool_call_card` is the canonical card for a single assistant tool call in a
+conversation timeline. Author props such as `tool_name`, `tool_kind`, `target`,
+`summary`, `status`, optional `args`, `expanded?`, `actor_handle`,
+`started_at`, `duration_ms`, and an optional `tool_result_summary` map for the
+paired result child. Use canonical `expand_toggled` interaction for expansion.
+Keep LiveView event fields and route helpers in the host layer.
+
You can also author `custom:*` types. They are accepted as widget types, but the
shipped validation/runtime does not automatically give them built-in signal
semantics. Some explicitly supported custom surfaces do have dedicated fallback
diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex
index 543b1f5c..5bbb3651 100644
--- a/lib/ash_ui/rendering/iur_adapter.ex
+++ b/lib/ash_ui/rendering/iur_adapter.ex
@@ -363,6 +363,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end
+ defp base_attributes(:tool_call_card, props) do
+ props
+ |> tool_call_card_opts()
+ |> IURComponents.tool_call_card()
+ |> Map.fetch!(:attributes)
+ end
+
defp base_attributes(:pipeline_stepper_horizontal = kind, props) do
component_attributes(
kind,
@@ -1075,6 +1082,47 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end
+ defp tool_call_card_opts(props) do
+ tool_call = props |> fetch(:tool_call, %{}) |> normalize_map()
+
+ %{
+ id: first_present(props, [:_element_id, :id]),
+ tool_name:
+ first_present(tool_call, [:tool_name]) ||
+ first_present(props, [:tool_name, :name, :label]),
+ tool_kind:
+ normalize_existing_atom(
+ first_present(tool_call, [:tool_kind]) || first_present(props, [:tool_kind]) || :other
+ ),
+ target: first_present(tool_call, [:target]) || first_present(props, [:target, :path]),
+ summary:
+ first_present(tool_call, [:summary]) || first_present(props, [:summary, :description]),
+ status:
+ normalize_existing_atom(
+ first_present(tool_call, [:status]) || first_present(props, [:status]) || :pending
+ ),
+ args: tool_call_args_value(tool_call, props),
+ expanded?:
+ boolean_present(tool_call, [:expanded?], boolean_present(props, [:expanded?], false)),
+ actor_handle:
+ first_present(tool_call, [:actor_handle]) || first_present(props, [:actor_handle]),
+ started_at: first_present(tool_call, [:started_at]) || first_present(props, [:started_at]),
+ duration_ms:
+ first_present(tool_call, [:duration_ms]) || first_present(props, [:duration_ms]),
+ approval_event_id:
+ first_present(tool_call, [:approval_event_id]) ||
+ first_present(props, [:approval_event_id]),
+ paired_result_event_id:
+ first_present(tool_call, [:paired_result_event_id]) ||
+ first_present(props, [:paired_result_event_id]),
+ expand_intent: first_present(props, [:expand_intent]) || :expand_toggled,
+ expand_interaction: fetch(props, :expand_interaction),
+ interactions: fetch(props, :interactions),
+ interaction: fetch(props, :interaction)
+ }
+ |> compact_map()
+ end
+
defp composer_query_preview_opts(props) do
preview = props |> fetch(:query_preview, %{}) |> normalize_map()
@@ -1362,6 +1410,14 @@ defmodule AshUI.Rendering.IURAdapter do
normalize_map(value)
end
+ defp tool_call_args_value(tool_call, props) do
+ cond do
+ not is_nil(first_present(tool_call, [:args])) -> first_present(tool_call, [:args])
+ not is_nil(fetch(props, :args)) -> fetch(props, :args)
+ true -> %{}
+ end
+ end
+
defp normalize_redline_segments(segments) when is_list(segments) do
Enum.map(segments, fn segment ->
segment
diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex
index b4d87f61..3246c28d 100644
--- a/lib/ash_ui/rendering/live_ui_adapter.ex
+++ b/lib/ash_ui/rendering/live_ui_adapter.ex
@@ -880,6 +880,81 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end
+ defp generate_heex(%{"type" => "tool_call_card"} = iur, _opts) do
+ props = iur["props"] || %{}
+ tool_call = props |> prop("tool_call", %{}) |> normalize_item()
+
+ tool_name =
+ escaped_text_prop(
+ tool_call,
+ "tool_name",
+ escaped_text_prop(props, ["tool_name", "name"], "Tool")
+ )
+
+ tool_kind =
+ escaped_text_prop(tool_call, "tool_kind", escaped_text_prop(props, "tool_kind", "other"))
+
+ target = escaped_text_prop(tool_call, "target", escaped_text_prop(props, "target", ""))
+ summary = escaped_text_prop(tool_call, "summary", escaped_text_prop(props, "summary", ""))
+ status = escaped_text_prop(tool_call, "status", escaped_text_prop(props, "status", "pending"))
+ expanded? = truthy_prop(tool_call, "expanded?", truthy_prop(props, "expanded?", false))
+ args = prop(tool_call, "args", prop(props, "args", %{}))
+ result = prop(tool_call, "tool_result_summary", prop(props, "tool_result_summary"))
+
+ iur_id = iur["id"] || iur[:id] || "tool-call-card"
+ details_id = "#{iur_id}-details"
+
+ args_html =
+ if expanded? do
+ ~s()
+ else
+ ""
+ end
+
+ result_html =
+ case result do
+ nil ->
+ ""
+
+ result ->
+ result = normalize_item(result)
+ event_id = escaped_text_prop(result, ["event_id", "result_event_id"], "")
+ result_status = escaped_text_prop(result, "status", "")
+ compact_output = escaped_text_prop(result, ["compact_output", "summary"], "")
+ diff_summary = escaped_text_prop(result, "diff_summary")
+ error? = truthy_prop(result, "error?", truthy_prop(result, "error", false))
+
+ """
+
+
+ #{compact_output}
+ #{if diff_summary, do: "#{diff_summary}
", else: ""}
+ #{if error?, do: "Error
", else: ""}
+
+ """
+ end
+
+ """
+
+
+ #{summary}
+ Details
+ #{args_html}
+ #{result_html}
+
+ """
+ end
+
defp generate_heex(%{"type" => "pipeline_stepper_horizontal"} = iur, _opts) do
props = iur["props"] || %{}
steps = prop(props, "steps", [])
diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex
index 63e9abf2..2f465a1d 100644
--- a/packages/live_ui/lib/live_ui/renderer.ex
+++ b/packages/live_ui/lib/live_ui/renderer.ex
@@ -76,6 +76,7 @@ defmodule LiveUi.Renderer do
:supervision_tree_viewer,
:table,
:thread_card,
+ :tool_call_card,
:tabs,
:text,
:text_input,
@@ -647,6 +648,49 @@ defmodule LiveUi.Renderer do
"""
end
+ # NOTE: `:tool_call_card` is a row/artifact component and member of
+ # `@component_kinds`; keep this native clause before the generic component
+ # fallback so expansion and paired-result rendering use the dedicated widget.
+ def render(%{element: %Element{kind: :tool_call_card}} = assigns) do
+ tool_call = get_in(assigns.element.attributes, [:tool_call]) || %{}
+
+ assigns =
+ assigns
+ |> assign(:tool_call, tool_call)
+ |> assign(:tool_call_args, tool_call_args(tool_call))
+ |> assign(:tool_result_summary, tool_result_summary(assigns.element))
+ |> assign(
+ :expand_attrs,
+ interaction_event_attrs(assigns.element, Map.get(assigns, :event_target))
+ )
+ |> assign(:style_attrs, style_rest(assigns.element))
+
+ ~H"""
+
+ """
+ end
+
# NOTE: `:segmented_button_group` is a member of `@component_kinds` through the
# `:form_control_and_composer` family, so the generic fallback below would
# shadow any later `:segmented_button_group` clause. Keep this specific clause
@@ -1967,6 +2011,23 @@ defmodule LiveUi.Renderer do
|> Enum.reject(&is_nil/1)
end
+ defp tool_result_summary(%Element{} = element) do
+ element
+ |> child_elements(:tool_result_summary)
+ |> List.first()
+ |> case do
+ %Element{attributes: attributes} -> Map.get(attributes, :tool_result_summary)
+ _other -> nil
+ end
+ end
+
+ defp tool_call_args(tool_call) do
+ case map_value(tool_call, :args, %{}) do
+ args when is_map(args) -> args
+ _other -> %{}
+ end
+ end
+
defp overlay_children(%Element{} = element) do
element.children
|> Enum.reject(&(&1.slot == :base))
diff --git a/packages/live_ui/lib/live_ui/widgets.ex b/packages/live_ui/lib/live_ui/widgets.ex
index b5f8a893..0e8cc9c0 100644
--- a/packages/live_ui/lib/live_ui/widgets.ex
+++ b/packages/live_ui/lib/live_ui/widgets.ex
@@ -15,6 +15,7 @@ defmodule LiveUi.Widgets do
| :display
| :content_identity_and_disclosure
| :form_control_and_composer
+ | :row_and_artifact
| :composition_behavior
| :layer_shell_and_callout
| :workflow_progress_and_status
@@ -35,6 +36,7 @@ defmodule LiveUi.Widgets do
:display,
:content_identity_and_disclosure,
:form_control_and_composer,
+ :row_and_artifact,
:composition_behavior,
:layer_shell_and_callout,
:workflow_progress_and_status
@@ -51,6 +53,7 @@ defmodule LiveUi.Widgets do
display_modules() ++
content_identity_and_disclosure_modules() ++
form_control_and_composer_modules() ++
+ row_and_artifact_modules() ++
composition_behavior_modules() ++
layer_shell_and_callout_modules() ++
workflow_progress_and_status_modules()
@@ -101,6 +104,11 @@ defmodule LiveUi.Widgets do
LiveUi.Widgets.FormControlAndComposer.modules()
end
+ @spec row_and_artifact_modules() :: [widget_module()]
+ def row_and_artifact_modules do
+ LiveUi.Widgets.RowAndArtifact.modules()
+ end
+
@spec composition_behavior_modules() :: [widget_module()]
def composition_behavior_modules do
LiveUi.Widgets.CompositionBehavior.modules()
diff --git a/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex
new file mode 100644
index 00000000..420be29f
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex
@@ -0,0 +1,12 @@
+defmodule LiveUi.Widgets.RowAndArtifact do
+ @moduledoc """
+ Reference surface for row and artifact widgets.
+ """
+
+ @modules [
+ LiveUi.Widgets.ToolCallCard
+ ]
+
+ @spec modules() :: [module()]
+ def modules, do: @modules
+end
diff --git a/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex
new file mode 100644
index 00000000..074d821d
--- /dev/null
+++ b/packages/live_ui/lib/live_ui/widgets/tool_call_card.ex
@@ -0,0 +1,186 @@
+defmodule LiveUi.Widgets.ToolCallCard do
+ @moduledoc """
+ Native tool-call card widget.
+
+ Renders one assistant tool call in a conversation timeline with canonical
+ expansion state and an optional paired result summary child.
+ """
+
+ use LiveUi.Component,
+ family: :row_and_artifact,
+ name: :tool_call_card,
+ slots: [],
+ events: [:expand_toggled]
+
+ LiveUi.Component.common_attrs()
+ attr(:tool_name, :string, required: true)
+ attr(:tool_kind, :atom, required: true)
+ attr(:target, :string, required: true)
+ attr(:summary, :string, required: true)
+ attr(:status, :atom, required: true)
+ attr(:args, :map, default: %{})
+ attr(:expanded?, :boolean, default: false)
+ attr(:actor_handle, :string, default: nil)
+ attr(:started_at, :any, default: nil)
+ attr(:duration_ms, :integer, default: nil)
+ attr(:approval_event_id, :string, default: nil)
+ attr(:paired_result_event_id, :string, default: nil)
+ attr(:tool_result_summary, :map, default: nil)
+ attr(:expand_attrs, :any, default: [])
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <%= @summary %>
+
+
+ <%= if @actor_handle do %>
+ <%= @actor_handle %>
+ <% end %>
+ <%= if @started_at do %>
+
+ <%= timestamp_label(@started_at) %>
+
+ <% end %>
+ <%= if @duration_ms do %>
+ <%= duration_label(@duration_ms) %>
+ <% end %>
+
+
+
+ Details
+
+
+ <%= if @expanded? do %>
+
+ <% end %>
+
+ <%= if @tool_result_summary do %>
+
+ <% end %>
+
+ """
+ end
+
+ defp tool_call_card_class(extra_class, status, expanded?) do
+ [
+ "live-ui-tool-call-card",
+ "live-ui-tool-call-card--#{status}",
+ expanded? && "is-expanded",
+ extra_class
+ ]
+ end
+
+ defp glyph_for_kind(:read), do: "R"
+ defp glyph_for_kind(:edit), do: "E"
+ defp glyph_for_kind(:write), do: "W"
+ defp glyph_for_kind(:bash), do: "$"
+ defp glyph_for_kind(:multiedit), do: "M"
+ defp glyph_for_kind(:other), do: "?"
+ defp glyph_for_kind(_other), do: "?"
+
+ defp status_class(nil), do: nil
+ defp status_class(status), do: "is-status-#{status}"
+
+ defp status_label(nil), do: ""
+
+ defp status_label(status) do
+ status
+ |> to_string()
+ |> String.replace("_", " ")
+ end
+
+ defp timestamp_iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
+ defp timestamp_iso8601(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_iso8601(ndt)
+ defp timestamp_iso8601(value) when is_binary(value), do: value
+ defp timestamp_iso8601(_other), do: nil
+
+ defp timestamp_label(value) when is_binary(value), do: value
+ defp timestamp_label(value), do: timestamp_iso8601(value) || ""
+
+ defp duration_label(duration_ms) when is_integer(duration_ms) and duration_ms < 1_000 do
+ "#{duration_ms}ms"
+ end
+
+ defp duration_label(duration_ms) when is_integer(duration_ms) do
+ seconds = Float.round(duration_ms / 1_000, 1)
+ "#{seconds}s"
+ end
+
+ defp duration_label(_duration_ms), do: ""
+
+ defp format_args(args) when is_map(args) do
+ inspect(args, pretty: true, limit: :infinity, printable_limit: :infinity)
+ end
+
+ defp format_args(_args), do: "%{}"
+
+ defp expand_button_attrs(attrs) when attrs in [nil, [], %{}],
+ do: %{:"phx-click" => "expand_toggled"}
+
+ defp expand_button_attrs(attrs), do: attrs
+
+ defp result_value(nil, _key), do: nil
+
+ defp result_value(result, key) when is_map(result) do
+ Map.get(result, key, Map.get(result, to_string(key)))
+ end
+
+ defp result_error?(result) do
+ result_value(result, :error?) == true or result_value(result, :error) == true
+ end
+end
diff --git a/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs b/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs
new file mode 100644
index 00000000..d32441dd
--- /dev/null
+++ b/packages/live_ui/test/live_ui/widgets/tool_call_card_test.exs
@@ -0,0 +1,192 @@
+defmodule LiveUi.Widgets.ToolCallCardTest do
+ use ExUnit.Case, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias LiveUi.Component
+ alias UnifiedIUR.Widgets.Components
+
+ describe "tool_call_card widget metadata" do
+ test "registers as a row_and_artifact widget with expand_toggled event" do
+ metadata = Component.metadata(LiveUi.Widgets.ToolCallCard)
+
+ assert metadata.mountable?
+ assert metadata.component_module == LiveUi.Widgets.ToolCallCard.Component
+ assert metadata.family == :row_and_artifact
+ assert metadata.name == :tool_call_card
+ assert :expand_toggled in metadata.events
+ end
+
+ test "is present in row_and_artifact aggregation" do
+ assert LiveUi.Widgets.ToolCallCard in LiveUi.Widgets.RowAndArtifact.modules()
+ assert LiveUi.Widgets.ToolCallCard in LiveUi.Widgets.row_and_artifact_modules()
+ end
+ end
+
+ describe "tool_call_card component rendering" do
+ test "renders the canonical root hooks and header content" do
+ html =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns()
+ )
+
+ assert html =~ ~s(data-live-ui-widget="tool-call-card")
+ assert html =~ ~s(data-tool-kind="bash")
+ assert html =~ ~s(data-status="pending")
+ assert html =~ "Bash"
+ assert html =~ "mix test"
+ assert html =~ "Run focused tests."
+ assert html =~ "live-ui-tool-call-card__header"
+ assert html =~ "live-ui-tool-call-card__status-badge"
+ end
+
+ test "renders status variants as data and BEM hooks" do
+ for status <- [:pending, :approved, :denied, :complete, :failed] do
+ html =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns(%{id: "tool-#{status}", status: status})
+ )
+
+ assert html =~ ~s(data-status="#{status}")
+ assert html =~ "live-ui-tool-call-card--#{status}"
+ assert html =~ "is-status-#{status}"
+ end
+ end
+
+ test "keeps args collapsed until expanded" do
+ collapsed =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns(%{id: "tool-collapsed", expanded?: false})
+ )
+
+ expanded =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns(%{id: "tool-expanded", expanded?: true})
+ )
+
+ refute collapsed =~ "live-ui-tool-call-card__args"
+ assert collapsed =~ ~s(aria-expanded="false")
+ assert expanded =~ "live-ui-tool-call-card__args"
+ assert expanded =~ "path: "test/live_ui"
+ assert expanded =~ ~s(aria-expanded="true")
+ end
+
+ test "renders expand button with fallback event attrs" do
+ html =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns(%{id: "tool-expand"})
+ )
+
+ assert html =~ "live-ui-tool-call-card__expand-toggle"
+ assert html =~ ~s|aria-label="Toggle tool call Bash (pending) details"|
+ assert html =~ ~s(phx-click="expand_toggled")
+ end
+
+ test "renders optional result summary child data" do
+ html =
+ render_component(
+ &LiveUi.Widgets.ToolCallCard.component/1,
+ base_assigns(%{
+ id: "tool-result",
+ status: :complete,
+ tool_result_summary: %{
+ event_id: "result-1",
+ status: :complete,
+ compact_output: "All tests passed.",
+ diff_summary: "No source diff.",
+ error?: false
+ }
+ })
+ )
+
+ assert html =~ "live-ui-tool-call-card__result"
+ assert html =~ "result-1"
+ assert html =~ "All tests passed."
+ assert html =~ "No source diff."
+ refute html =~ "live-ui-tool-call-card__result-error"
+ end
+ end
+
+ describe "renderer dispatch" do
+ test "tool_call_card kind is in supported_kinds" do
+ assert :tool_call_card in LiveUi.Renderer.supported_kinds()
+ end
+
+ test "renders via dedicated renderer clause with canonical interaction attrs" do
+ element =
+ Components.tool_call_card(
+ id: "tool-call-r1",
+ tool_name: "Bash",
+ tool_kind: :bash,
+ target: "mix test",
+ summary: "Run focused tests.",
+ status: :pending,
+ args: %{cmd: "mix test"}
+ )
+
+ html =
+ render_component(&LiveUi.Renderer.render/1, %{
+ element: element,
+ event_target: "#runtime-host"
+ })
+
+ assert html =~ ~s(data-live-ui-widget="tool-call-card")
+ assert html =~ ~s(phx-click="canonical_interaction")
+ assert html =~ ~s(phx-target="#runtime-host")
+ assert html =~ ~s(phx-value-widget="tool_call_card")
+ assert html =~ ~s(phx-value-element_id="tool-call-r1")
+ refute html =~ ~s(data-live-ui-component-kind="tool_call_card")
+ refute html =~ ~s(data-live-ui-unsupported-native-component)
+ end
+
+ test "renderer propagates expanded state and paired result child" do
+ element =
+ Components.tool_call_card(
+ id: "tool-call-r2",
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read the file.",
+ status: :complete,
+ args: %{path: "lib/ash_ui.ex"},
+ expanded?: true,
+ paired_result_event_id: "tool-result-r2",
+ tool_result_summary: %{
+ event_id: "tool-result-r2",
+ status: :complete,
+ compact_output: "Loaded file.",
+ error?: false
+ }
+ )
+
+ html = render_component(&LiveUi.Renderer.render/1, %{element: element})
+
+ assert html =~ ~s(aria-expanded="true")
+ assert html =~ "live-ui-tool-call-card__args"
+ assert html =~ "Loaded file."
+ assert html =~ "tool-result-r2"
+ refute html =~ "Unsupported canonical kind"
+ end
+ end
+
+ defp base_assigns(overrides \\ %{}) do
+ Map.merge(
+ %{
+ id: "tool-card",
+ tool_name: "Bash",
+ tool_kind: :bash,
+ target: "mix test",
+ summary: "Run focused tests.",
+ status: :pending,
+ args: %{cmd: "mix test", path: "test/live_ui/widgets/tool_call_card_test.exs"},
+ expanded?: false
+ },
+ overrides
+ )
+ end
+end
diff --git a/packages/unified_iur/lib/unified_iur/fixtures.ex b/packages/unified_iur/lib/unified_iur/fixtures.ex
index 008e138d..dee9d61c 100644
--- a/packages/unified_iur/lib/unified_iur/fixtures.ex
+++ b/packages/unified_iur/lib/unified_iur/fixtures.ex
@@ -1198,6 +1198,48 @@ defmodule UnifiedIUR.Fixtures do
status: :pending,
actor_handle: "@codex",
proposed_at: "2026-05-27T10:00:00Z"
+ )},
+ {:content,
+ Components.collection_picker(
+ id: "component-collection-picker",
+ picker_id: "adr-picker",
+ title: "ADRs",
+ query: "boundary",
+ filters: [%{id: :all, label: "All", selected?: true, count: 3}],
+ items: [%{id: :adr_0001, label: "ADR 0001", description: "Stream workspace doc body"}],
+ suggestions: []
+ )},
+ {:content,
+ Components.thread_card(
+ id: "component-thread-card",
+ thread_id: "thread:spec-review-0001",
+ title: "Spec review discussion",
+ seed_quote: "Should the runtime own this transition?",
+ reply_count: 3,
+ participants: [%{actor_name: "Pascal", avatar: %{initials: "PC"}}],
+ progress_pct: 0.6
+ )},
+ {:content,
+ Components.composer_query_preview(
+ id: "component-query-preview",
+ composer_id: "composer-main",
+ query: "release blockers",
+ preview_state: :ready,
+ explanation: "Two likely blockers found.",
+ findings: [
+ %{id: "finding-1", n: 1, snippet: "Conformance missing", confidence: 0.88}
+ ]
+ )},
+ {:content,
+ Components.tool_call_card(
+ id: "component-tool-call-card",
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ariston_ui/workspace.ex",
+ summary: "Read workspace module",
+ status: :complete,
+ args: %{file_path: "lib/ariston_ui/workspace.ex"},
+ expanded?: false
)}
],
id: "component-safety-fixture"
diff --git a/packages/unified_iur/lib/unified_iur/validate.ex b/packages/unified_iur/lib/unified_iur/validate.ex
index f720b821..7a3b394b 100644
--- a/packages/unified_iur/lib/unified_iur/validate.ex
+++ b/packages/unified_iur/lib/unified_iur/validate.ex
@@ -124,6 +124,16 @@ defmodule UnifiedIUR.Validate do
construct_family: :widget_components,
guidance: "Represent artifact counts as a list of maps with key, value, and optional label."
},
+ invalid_tool_call_card: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent tool_call_card with required tool identity, status, target, summary, args, and renderer-independent expansion intent."
+ },
+ invalid_tool_result_summary: %{
+ construct_family: :widget_components,
+ guidance:
+ "Represent tool_result_summary as the single paired result child with event id, status, compact output, and optional diff or error marker."
+ },
invalid_rail_contract: %{
construct_family: :widget_components,
guidance:
@@ -208,6 +218,8 @@ defmodule UnifiedIUR.Validate do
@redline_states [:keep, :insert, :delete, :accepted, :rejected]
@artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic]
@artifact_badge_tones [:positive, :warning, :danger, :info, :neutral]
+ @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other]
+ @tool_call_statuses [:pending, :approved, :denied, :complete, :failed]
@rail_sides [:right]
@query_preview_states [:loading, :ready, :empty, :error]
@propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
@@ -641,6 +653,26 @@ defmodule UnifiedIUR.Validate do
)
end
+ defp validate_component_contracts(%Element{
+ kind: :tool_call_card,
+ attributes: attributes,
+ children: children
+ }) do
+ tool_call = Map.get(attributes, :tool_call, %{})
+
+ []
+ |> Kernel.++(validate_tool_call_shape(tool_call))
+ |> Kernel.++(
+ validate_tool_call_result_children(children, fetch(tool_call, :paired_result_event_id))
+ )
+ end
+
+ defp validate_component_contracts(%Element{kind: :tool_result_summary, attributes: attributes}) do
+ attributes
+ |> Map.get(:tool_result_summary, %{})
+ |> validate_tool_result_summary_shape([:attributes, :tool_result_summary])
+ end
+
defp validate_component_contracts(%Element{kind: :meter_thin, attributes: attributes}) do
meter = Map.get(attributes, :meter, %{})
current = fetch(meter, :current)
@@ -729,6 +761,227 @@ defmodule UnifiedIUR.Validate do
defp validate_component_contracts(_element), do: []
+ defp validate_tool_call_shape(tool_call) when is_map(tool_call) do
+ []
+ |> maybe_add(
+ not non_blank_string?(fetch(tool_call, :tool_name)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card requires tool_name as a non-blank string",
+ path: [:attributes, :tool_call, :tool_name],
+ details: %{tool_name: inspect(fetch(tool_call, :tool_name))}
+ )
+ )
+ |> maybe_add(
+ fetch(tool_call, :tool_kind) not in @tool_call_kinds,
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card tool_kind must be one of #{inspect(@tool_call_kinds)}",
+ path: [:attributes, :tool_call, :tool_kind],
+ details: %{tool_kind: inspect(fetch(tool_call, :tool_kind))}
+ )
+ )
+ |> maybe_add(
+ not non_blank_string?(fetch(tool_call, :target)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card requires target as a non-blank string",
+ path: [:attributes, :tool_call, :target],
+ details: %{target: inspect(fetch(tool_call, :target))}
+ )
+ )
+ |> maybe_add(
+ not is_binary(fetch(tool_call, :summary)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card requires summary as a string",
+ path: [:attributes, :tool_call, :summary],
+ details: %{summary: inspect(fetch(tool_call, :summary))}
+ )
+ )
+ |> maybe_add(
+ fetch(tool_call, :status) not in @tool_call_statuses,
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card status must be one of #{inspect(@tool_call_statuses)}",
+ path: [:attributes, :tool_call, :status],
+ details: %{status: inspect(fetch(tool_call, :status))}
+ )
+ )
+ |> maybe_add(
+ not is_map(fetch(tool_call, :args)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card args must be a map",
+ path: [:attributes, :tool_call, :args],
+ details: %{args: inspect(fetch(tool_call, :args))}
+ )
+ )
+ |> maybe_add(
+ has_key?(tool_call, :expanded?) and not is_boolean(fetch(tool_call, :expanded?)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card expanded? must be a boolean",
+ path: [:attributes, :tool_call, :expanded?],
+ details: %{expanded?: inspect(fetch(tool_call, :expanded?))}
+ )
+ )
+ |> maybe_add(
+ has_key?(tool_call, :duration_ms) and
+ not non_negative_integer?(fetch(tool_call, :duration_ms)),
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card duration_ms must be a non-negative integer",
+ path: [:attributes, :tool_call, :duration_ms],
+ details: %{duration_ms: inspect(fetch(tool_call, :duration_ms))}
+ )
+ )
+ end
+
+ defp validate_tool_call_shape(_tool_call) do
+ [
+ Error.new(
+ :invalid_tool_call_card,
+ "tool_call_card attributes.tool_call must be a map",
+ path: [:attributes, :tool_call]
+ )
+ ]
+ end
+
+ defp validate_tool_call_result_children(children, paired_result_event_id)
+ when is_list(children) do
+ present_children =
+ children
+ |> Enum.with_index()
+ |> Enum.filter(fn
+ {%Child{element: %Element{}}, _index} -> true
+ _other -> false
+ end)
+
+ result_children =
+ Enum.filter(present_children, fn {%Child{} = child, _index} ->
+ tool_result_summary_child?(child)
+ end)
+
+ non_result_errors =
+ present_children
+ |> Enum.reject(fn {%Child{} = child, _index} -> tool_result_summary_child?(child) end)
+ |> Enum.map(fn {_child, index} ->
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_call_card accepts only a tool_result_summary child",
+ path: [:children, index]
+ )
+ end)
+
+ count_errors =
+ maybe_add(
+ [],
+ length(result_children) > 1,
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_call_card accepts at most one tool_result_summary child",
+ path: [:children],
+ details: %{count: length(result_children)}
+ )
+ )
+
+ child_errors =
+ result_children
+ |> Enum.take(1)
+ |> Enum.flat_map(fn {%Child{element: element}, index} ->
+ summary = Map.get(element.attributes, :tool_result_summary, %{})
+ event_id = tool_result_event_id(summary)
+
+ validate_tool_result_summary_shape(summary, [
+ :children,
+ index,
+ :attributes,
+ :tool_result_summary
+ ]) ++
+ maybe_add(
+ [],
+ non_blank_string?(paired_result_event_id) and event_id != paired_result_event_id,
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary event_id must match paired_result_event_id",
+ path: [:children, index, :attributes, :tool_result_summary, :event_id],
+ details: %{
+ event_id: inspect(event_id),
+ paired_result_event_id: paired_result_event_id
+ }
+ )
+ )
+ end)
+
+ non_result_errors ++ count_errors ++ child_errors
+ end
+
+ defp validate_tool_call_result_children(_children, _paired_result_event_id), do: []
+
+ defp tool_result_summary_child?(%Child{
+ slot: slot,
+ element: %Element{kind: :tool_result_summary}
+ })
+ when slot in [:tool_result_summary, "tool_result_summary"],
+ do: true
+
+ defp tool_result_summary_child?(_child), do: false
+
+ defp validate_tool_result_summary_shape(summary, path) when is_map(summary) do
+ []
+ |> maybe_add(
+ not non_blank_string?(tool_result_event_id(summary)),
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary requires event_id as a non-blank string",
+ path: path ++ [:event_id],
+ details: %{event_id: inspect(tool_result_event_id(summary))}
+ )
+ )
+ |> maybe_add(
+ fetch(summary, :status) not in @tool_call_statuses,
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary status must be one of #{inspect(@tool_call_statuses)}",
+ path: path ++ [:status],
+ details: %{status: inspect(fetch(summary, :status))}
+ )
+ )
+ |> maybe_add(
+ not is_binary(fetch(summary, :compact_output)),
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary requires compact_output as a string",
+ path: path ++ [:compact_output],
+ details: %{compact_output: inspect(fetch(summary, :compact_output))}
+ )
+ )
+ |> maybe_add(
+ has_key?(summary, :error?) and not is_boolean(fetch(summary, :error?)),
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary error? must be a boolean",
+ path: path ++ [:error?],
+ details: %{error?: inspect(fetch(summary, :error?))}
+ )
+ )
+ end
+
+ defp validate_tool_result_summary_shape(_summary, path) do
+ [
+ Error.new(
+ :invalid_tool_result_summary,
+ "tool_result_summary attributes must be a map",
+ path: path
+ )
+ ]
+ end
+
+ defp tool_result_event_id(summary) do
+ fetch(summary, :event_id, fetch(summary, :result_event_id))
+ end
+
defp validate_subject_shape(subject) when is_map(subject) do
[]
|> maybe_add(
diff --git a/packages/unified_iur/lib/unified_iur/widgets/components.ex b/packages/unified_iur/lib/unified_iur/widgets/components.ex
index a577db0d..0c70ed7b 100644
--- a/packages/unified_iur/lib/unified_iur/widgets/components.ex
+++ b/packages/unified_iur/lib/unified_iur/widgets/components.ex
@@ -44,7 +44,8 @@ defmodule UnifiedIUR.Widgets.Components do
@row_artifact_kinds [
:list_item_multi_column,
:artifact_row,
- :thread_card
+ :thread_card,
+ :tool_call_card
]
@artifact_kinds [
@@ -81,6 +82,8 @@ defmodule UnifiedIUR.Widgets.Components do
@query_preview_states [:loading, :ready, :empty, :error]
@propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
+ @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other]
+ @tool_call_statuses [:pending, :approved, :denied, :complete, :failed]
@redline_code_kinds [
:redline_inline,
@@ -402,6 +405,79 @@ defmodule UnifiedIUR.Widgets.Components do
)
end
+ @spec tool_call_card(opts()) :: Element.t()
+ def tool_call_card(opts \\ []) do
+ opts = normalize_opts(opts)
+
+ tool_name = option(opts, :tool_name)
+ tool_kind = option(opts, :tool_kind)
+ target = option(opts, :target)
+ summary = option(opts, :summary)
+ status = option(opts, :status)
+ args = option(opts, :args)
+ expanded? = option(opts, :expanded?, false) || false
+ duration_ms = option(opts, :duration_ms)
+ paired_result_event_id = option(opts, :paired_result_event_id)
+ tool_result_summary = normalize_tool_result_summary_child!(opts, paired_result_event_id)
+
+ unless non_blank_string?(tool_name) do
+ raise ArgumentError, "tool_call_card requires a non-blank :tool_name string"
+ end
+
+ unless tool_kind in @tool_call_kinds do
+ raise ArgumentError, "tool_call_card :tool_kind must be one of #{inspect(@tool_call_kinds)}"
+ end
+
+ unless non_blank_string?(target) do
+ raise ArgumentError, "tool_call_card requires a non-blank :target string"
+ end
+
+ unless is_binary(summary) do
+ raise ArgumentError, "tool_call_card requires a :summary string"
+ end
+
+ unless status in @tool_call_statuses do
+ raise ArgumentError, "tool_call_card :status must be one of #{inspect(@tool_call_statuses)}"
+ end
+
+ unless is_map(args) do
+ raise ArgumentError, "tool_call_card :args must be a map"
+ end
+
+ unless is_boolean(expanded?) do
+ raise ArgumentError, "tool_call_card :expanded? must be a boolean"
+ end
+
+ unless is_nil(duration_ms) or non_negative_integer?(duration_ms) do
+ raise ArgumentError, "tool_call_card :duration_ms must be a non-negative integer"
+ end
+
+ opts = put_tool_call_expand_interaction(opts, tool_name)
+
+ build_component(
+ :tool_call_card,
+ :row_and_artifact,
+ %{
+ tool_call:
+ %{
+ tool_name: tool_name,
+ tool_kind: tool_kind,
+ target: target,
+ summary: summary,
+ status: status,
+ args: Map.new(args),
+ expanded?: expanded?
+ }
+ |> maybe_put(:actor_handle, option(opts, :actor_handle))
+ |> maybe_put(:started_at, option(opts, :started_at))
+ |> maybe_put(:duration_ms, duration_ms)
+ |> maybe_put(:approval_event_id, option(opts, :approval_event_id))
+ |> maybe_put(:paired_result_event_id, paired_result_event_id)
+ },
+ Map.put(opts, :children, List.wrap(tool_result_summary))
+ )
+ end
+
@spec pipeline_stepper_horizontal([keyword() | map()], opts()) :: Element.t()
def pipeline_stepper_horizontal(steps, opts \\ []) when is_list(steps) do
opts = normalize_opts(opts)
@@ -1375,6 +1451,32 @@ defmodule UnifiedIUR.Widgets.Components do
end
end
+ defp put_tool_call_expand_interaction(opts, tool_name) do
+ cond do
+ explicit_interactions?(opts) ->
+ opts
+
+ true ->
+ Map.put(opts, :interactions, [tool_call_expand_interaction(opts, tool_name)])
+ end
+ end
+
+ defp tool_call_expand_interaction(opts, tool_name) do
+ case option(opts, :expand_interaction) do
+ nil ->
+ Interaction.command(
+ intent: option(opts, :expand_intent, :expand_toggled),
+ element_id: option(opts, :id),
+ entity: tool_name,
+ command: :expand_toggled,
+ value: option(opts, :target)
+ )
+
+ interaction ->
+ Interaction.new(interaction)
+ end
+ end
+
defp put_query_preview_interactions(opts, composer_id, query) do
cond do
Map.has_key?(opts, :interactions) or Map.has_key?(opts, "interactions") or
@@ -1707,6 +1809,86 @@ defmodule UnifiedIUR.Widgets.Components do
end
end
+ defp normalize_tool_result_summary_child!(opts, paired_result_event_id) do
+ summaries = tool_result_summary_inputs(opts)
+
+ case summaries do
+ [] ->
+ nil
+
+ [summary] ->
+ summary = normalize_tool_result_summary!(summary)
+ event_id = Map.fetch!(summary, :event_id)
+
+ if non_blank_string?(paired_result_event_id) and event_id != paired_result_event_id do
+ raise ArgumentError,
+ "tool_call_card :tool_result_summary event_id must match :paired_result_event_id"
+ end
+
+ {:tool_result_summary,
+ Element.new(:widget, :tool_result_summary,
+ id: event_id,
+ attributes: %{tool_result_summary: summary}
+ )}
+
+ _more ->
+ raise ArgumentError, "tool_call_card accepts at most one :tool_result_summary child"
+ end
+ end
+
+ defp tool_result_summary_inputs(opts) do
+ cond do
+ not is_nil(option(opts, :tool_result_summaries)) ->
+ option(opts, :tool_result_summaries)
+ |> List.wrap()
+
+ is_nil(option(opts, :tool_result_summary)) ->
+ []
+
+ keyword_list?(option(opts, :tool_result_summary)) ->
+ [option(opts, :tool_result_summary)]
+
+ is_list(option(opts, :tool_result_summary)) ->
+ option(opts, :tool_result_summary)
+
+ true ->
+ [option(opts, :tool_result_summary)]
+ end
+ end
+
+ defp normalize_tool_result_summary!(summary) when is_map(summary) or is_list(summary) do
+ summary = normalize_map(summary)
+ event_id = option(summary, :event_id, option(summary, :result_event_id))
+ status = option(summary, :status)
+ compact_output = option(summary, :compact_output, option(summary, :summary))
+
+ unless non_blank_string?(event_id) do
+ raise ArgumentError, "tool_result_summary requires a non-blank :event_id string"
+ end
+
+ unless status in @tool_call_statuses do
+ raise ArgumentError,
+ "tool_result_summary :status must be one of #{inspect(@tool_call_statuses)}"
+ end
+
+ unless is_binary(compact_output) do
+ raise ArgumentError, "tool_result_summary requires a :compact_output string"
+ end
+
+ %{
+ event_id: event_id,
+ status: status,
+ compact_output: compact_output
+ }
+ |> maybe_put(:diff_summary, option(summary, :diff_summary))
+ |> maybe_put(:error?, option(summary, :error?, option(summary, :error)))
+ end
+
+ defp normalize_tool_result_summary!(_summary) do
+ raise ArgumentError, "tool_result_summary must be a map"
+ end
+
+
defp normalize_query_preview_state!(state) when state in @query_preview_states, do: state
defp normalize_query_preview_state!(state) when is_binary(state) do
@@ -1842,8 +2024,10 @@ defmodule UnifiedIUR.Widgets.Components do
defp empty_map_to_nil(value), do: value
defp non_empty_string?(value), do: is_binary(value) and byte_size(value) > 0
+ defp non_blank_string?(value), do: is_binary(value) and String.trim(value) != ""
defp non_negative_integer?(value), do: is_integer(value) and value >= 0
defp positive_integer?(value), do: is_integer(value) and value > 0
+ defp keyword_list?(value), do: is_list(value) and Keyword.keyword?(value)
defp normalized_confidence?(value) when is_integer(value) or is_float(value) do
value >= 0.0 and value <= 1.0
diff --git a/packages/unified_iur/test/unified_iur/validate_test.exs b/packages/unified_iur/test/unified_iur/validate_test.exs
index 35845630..eb17f7ed 100644
--- a/packages/unified_iur/test/unified_iur/validate_test.exs
+++ b/packages/unified_iur/test/unified_iur/validate_test.exs
@@ -350,4 +350,120 @@ defmodule UnifiedIUR.ValidateTest do
:invalid_artifact_count
]
end
+
+ test "recognizes and validates canonical tool_call_card vocabulary" do
+ valid_tool_call =
+ Components.tool_call_card(
+ id: "tool-call-1",
+ tool_name: "Bash",
+ tool_kind: :bash,
+ target: "mix test",
+ summary: "Run the focused test suite.",
+ status: :complete,
+ args: %{cmd: "mix test"},
+ paired_result_event_id: "tool-result-1",
+ tool_result_summary: %{
+ event_id: "tool-result-1",
+ status: :complete,
+ compact_output: "Tests passed.",
+ error?: false
+ }
+ )
+
+ assert :tool_call_card in Components.kinds()
+ assert :ok = Validate.element(valid_tool_call)
+ end
+
+ test "rejects malformed raw tool_call_card payloads with structured diagnostics" do
+ invalid_tool_call =
+ Element.new(:widget, :tool_call_card,
+ attributes: %{
+ component: %{family: :row_and_artifact, kind: :tool_call_card},
+ tool_call: %{
+ tool_name: " ",
+ tool_kind: :delete,
+ target: "",
+ summary: nil,
+ status: :running,
+ args: []
+ }
+ }
+ )
+
+ assert {:error, errors} = Validate.element(invalid_tool_call)
+ assert Enum.all?(errors, &(&1.code == :invalid_tool_call_card))
+ end
+
+ test "rejects invalid tool_result_summary children" do
+ duplicate_results =
+ Element.new(:widget, :tool_call_card,
+ attributes: valid_tool_call_attrs("result-1"),
+ children: [
+ {:tool_result_summary, tool_result_summary_element("result-1")},
+ {:tool_result_summary, tool_result_summary_element("result-2")}
+ ]
+ )
+
+ missing_event_id =
+ Element.new(:widget, :tool_call_card,
+ attributes: valid_tool_call_attrs(nil),
+ children: [
+ {:tool_result_summary,
+ Element.new(:widget, :tool_result_summary,
+ attributes: %{
+ tool_result_summary: %{status: :complete, compact_output: "ok"}
+ }
+ )}
+ ]
+ )
+
+ mismatched_pair =
+ Element.new(:widget, :tool_call_card,
+ attributes: valid_tool_call_attrs("expected-result"),
+ children: [
+ {:tool_result_summary, tool_result_summary_element("actual-result")}
+ ]
+ )
+
+ assert {:error, duplicate_errors} = Validate.element(duplicate_results)
+ assert Enum.any?(duplicate_errors, &(&1.code == :invalid_tool_result_summary))
+
+ assert {:error, missing_errors} = Validate.element(missing_event_id)
+ assert Enum.any?(missing_errors, &(&1.code == :invalid_tool_result_summary))
+
+ assert {:error, mismatch_errors} = Validate.element(mismatched_pair)
+ assert Enum.any?(mismatch_errors, &(&1.code == :invalid_tool_result_summary))
+ end
+
+ defp valid_tool_call_attrs(paired_result_event_id) do
+ %{
+ component: %{family: :row_and_artifact, kind: :tool_call_card},
+ tool_call:
+ %{
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read the file.",
+ status: :complete,
+ args: %{},
+ expanded?: false
+ }
+ |> maybe_put(:paired_result_event_id, paired_result_event_id)
+ }
+ end
+
+ defp tool_result_summary_element(event_id) do
+ Element.new(:widget, :tool_result_summary,
+ attributes: %{
+ tool_result_summary: %{
+ event_id: event_id,
+ status: :complete,
+ compact_output: "ok"
+ }
+ }
+ )
+ end
+
+ defp maybe_put(map, _key, nil), do: map
+ defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
index 0cc3c987..a3990013 100644
--- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs
+++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs
@@ -27,7 +27,8 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
assert Components.row_artifact_kinds() == [
:list_item_multi_column,
:artifact_row,
- :thread_card
+ :thread_card,
+ :tool_call_card
]
assert Components.artifact_kinds() == [:pr, :doc, :spec, :file, :grain, :generic]
@@ -176,6 +177,28 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
progress_pct: 0.4
)
+ tool_call =
+ Components.tool_call_card(
+ id: "tool-call-read",
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read the package entrypoint.",
+ status: :complete,
+ args: %{path: "lib/ash_ui.ex"},
+ expanded?: true,
+ actor_handle: "@codex",
+ duration_ms: 42,
+ paired_result_event_id: "event-result-1",
+ tool_result_summary: %{
+ event_id: "event-result-1",
+ status: :complete,
+ compact_output: "Loaded 120 lines.",
+ diff_summary: "No changes.",
+ error?: false
+ }
+ )
+
assert segmented.attributes.selection == %{
presentation: :segmented_button_group,
multiple?: false,
@@ -268,6 +291,45 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
]
assert [%Interaction{family: :open, intent: :open_thread}] = thread.attributes.interactions
+
+ assert tool_call.attributes.component == %{
+ family: :row_and_artifact,
+ kind: :tool_call_card
+ }
+
+ assert tool_call.attributes.tool_call == %{
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read the package entrypoint.",
+ status: :complete,
+ args: %{path: "lib/ash_ui.ex"},
+ expanded?: true,
+ actor_handle: "@codex",
+ duration_ms: 42,
+ paired_result_event_id: "event-result-1"
+ }
+
+ assert [%Interaction{family: :command, intent: :expand_toggled}] =
+ tool_call.attributes.interactions
+
+ assert [
+ %{
+ slot: :tool_result_summary,
+ element: %Element{
+ kind: :tool_result_summary,
+ attributes: %{
+ tool_result_summary: %{
+ event_id: "event-result-1",
+ status: :complete,
+ compact_output: "Loaded 120 lines.",
+ diff_summary: "No changes.",
+ error?: false
+ }
+ }
+ }
+ }
+ ] = tool_call.children
end
test "validates canonical thread card identity and progress" do
@@ -302,6 +364,143 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do
end
end
+ describe "tool_call_card payload validation" do
+ test "rejects missing required fields" do
+ assert_raise ArgumentError, ~r/non-blank :tool_name/, fn ->
+ Components.tool_call_card(
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :pending,
+ args: %{}
+ )
+ end
+
+ assert_raise ArgumentError, ~r/requires a :summary string/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ status: :pending,
+ args: %{}
+ )
+ end
+ end
+
+ test "rejects blank tool_name and target" do
+ assert_raise ArgumentError, ~r/non-blank :tool_name/, fn ->
+ Components.tool_call_card(
+ tool_name: " ",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :pending,
+ args: %{}
+ )
+ end
+
+ assert_raise ArgumentError, ~r/non-blank :target/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: " ",
+ summary: "Read file",
+ status: :pending,
+ args: %{}
+ )
+ end
+ end
+
+ test "rejects non-map args" do
+ assert_raise ArgumentError, ~r/:args must be a map/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :pending,
+ args: [path: "lib/ash_ui.ex"]
+ )
+ end
+ end
+
+ test "rejects unknown tool_kind" do
+ assert_raise ArgumentError, ~r/:tool_kind must be one of/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :delete,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :pending,
+ args: %{}
+ )
+ end
+ end
+
+ test "rejects unknown status" do
+ assert_raise ArgumentError, ~r/:status must be one of/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :running,
+ args: %{}
+ )
+ end
+ end
+
+ test "rejects more than one tool_result_summary" do
+ assert_raise ArgumentError, ~r/at most one :tool_result_summary/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :complete,
+ args: %{},
+ tool_result_summary: [
+ %{event_id: "result-1", status: :complete, compact_output: "ok"},
+ %{event_id: "result-2", status: :complete, compact_output: "ok"}
+ ]
+ )
+ end
+ end
+
+ test "rejects tool_result_summary without an event id" do
+ assert_raise ArgumentError, ~r/non-blank :event_id/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :complete,
+ args: %{},
+ tool_result_summary: %{status: :complete, compact_output: "ok"}
+ )
+ end
+ end
+
+ test "rejects mismatched paired result event id" do
+ assert_raise ArgumentError, ~r/event_id must match :paired_result_event_id/, fn ->
+ Components.tool_call_card(
+ tool_name: "Read",
+ tool_kind: :read,
+ target: "lib/ash_ui.ex",
+ summary: "Read file",
+ status: :complete,
+ args: %{},
+ paired_result_event_id: "result-expected",
+ tool_result_summary: %{
+ event_id: "result-actual",
+ status: :complete,
+ compact_output: "ok"
+ }
+ )
+ end
+ end
+ end
+
test "validates canonical collection picker shape" do
assert_raise ArgumentError, ~r/non-empty :picker_id/, fn ->
Components.collection_picker(items: [])
diff --git a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
index 1944218b..212b6c4c 100644
--- a/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/dsl/entities/widget_components.ex
@@ -15,6 +15,8 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
@presence_states [:active, :away, :offline, :focus, :do_not_disturb]
@artifact_kinds [:pr, :doc, :spec, :file, :grain, :generic]
@propose_new_doc_statuses [:pending, :accepted, :rejected, :archived]
+ @tool_call_kinds [:read, :edit, :write, :bash, :multiedit, :other]
+ @tool_call_statuses [:pending, :approved, :denied, :complete, :failed]
@spec entities() :: [Spark.Dsl.Entity.t()]
def entities do
@@ -131,6 +133,24 @@ defmodule UnifiedUi.Dsl.Entities.WidgetComponents do
last_activity_at: [type: :any, required: false],
open_intent: [type: :any, required: false],
summary: [type: :string, required: false]
+ ),
+ leaf(
+ :tool_call_card,
+ @row_artifact_family,
+ tool_name: [type: :string, required: true],
+ tool_kind: [type: {:in, @tool_call_kinds}, required: true],
+ target: [type: :string, required: true],
+ summary: [type: :string, required: true],
+ status: [type: {:in, @tool_call_statuses}, required: true],
+ args: [type: :any, required: true],
+ expanded?: [type: :boolean, required: false, default: false],
+ actor_handle: [type: :string, required: false],
+ started_at: [type: :any, required: false],
+ duration_ms: [type: :integer, required: false],
+ approval_event_id: [type: :string, required: false],
+ paired_result_event_id: [type: :string, required: false],
+ tool_result_summary: [type: :any, required: false],
+ expand_intent: [type: :atom, required: false]
)
]
end
diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex
index ffda14a6..98d61d58 100644
--- a/packages/unified_ui/lib/unified_ui/widget_components.ex
+++ b/packages/unified_ui/lib/unified_ui/widget_components.ex
@@ -106,6 +106,13 @@ defmodule UnifiedUi.WidgetComponents do
"Conversation thread preview artifact with participants, seed quote, progress, and canonical open interaction.",
aliases: []
},
+ %{
+ kind: :tool_call_card,
+ family: :row_and_artifact,
+ summary:
+ "Assistant tool-call artifact with status, target, args, expansion state, and optional paired result summary.",
+ aliases: []
+ },
%{
kind: :sticky_frosted_header,
family: :layer_shell_and_callout,
diff --git a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
index b9b42081..ad2e0d00 100644
--- a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
+++ b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs
@@ -29,7 +29,12 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
:collection_picker,
:mode_nav
],
- row_and_artifact: [:list_item_multi_column, :artifact_row, :thread_card],
+ row_and_artifact: [
+ :list_item_multi_column,
+ :artifact_row,
+ :thread_card,
+ :tool_call_card
+ ],
workflow_progress_and_status: [
:pipeline_stepper_horizontal,
:segmented_progress_bar,
@@ -71,6 +76,7 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do
assert :propose_new_doc_card in kinds
assert :collection_picker in kinds
assert :workflow_progress_status_card in kinds
+ assert :tool_call_card in kinds
end
test "canonical name lookup accepts AshUi aliases with diagnostics" do
diff --git a/test/ash_ui/phase_31_package_boundary_test.exs b/test/ash_ui/phase_31_package_boundary_test.exs
index 9b9c622e..fd7d40bb 100644
--- a/test/ash_ui/phase_31_package_boundary_test.exs
+++ b/test/ash_ui/phase_31_package_boundary_test.exs
@@ -45,7 +45,12 @@ defmodule AshUI.Phase31PackageBoundaryTest do
:mode_nav
]
- assert families.row_and_artifact == [:list_item_multi_column, :artifact_row, :thread_card]
+ assert families.row_and_artifact == [
+ :list_item_multi_column,
+ :artifact_row,
+ :thread_card,
+ :tool_call_card
+ ]
assert families.workflow_progress_and_status == [
:pipeline_stepper_horizontal,
diff --git a/test/ash_ui/rendering/iur_adapter_test.exs b/test/ash_ui/rendering/iur_adapter_test.exs
index 264602c2..b746439c 100644
--- a/test/ash_ui/rendering/iur_adapter_test.exs
+++ b/test/ash_ui/rendering/iur_adapter_test.exs
@@ -368,6 +368,50 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert :ok = UnifiedIUR.Validate.element(child.element)
end
+ test "routes tool_call_card kind through row_and_artifact family with canonical expand interaction" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "tool-call-card-screen",
+ name: "tool_call_card_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:tool_call_card,
+ id: "tool-call-1",
+ props: %{
+ "tool_name" => "Bash",
+ "tool_kind" => "bash",
+ "target" => "mix test",
+ "summary" => "Run the focused suite.",
+ "status" => "pending",
+ "args" => %{"cmd" => "mix test"},
+ "expanded?" => false
+ }
+ )
+ ]
+ )
+
+ assert {:ok, canonical} = IURAdapter.to_canonical(ash_iur)
+ [child] = canonical.children
+ assert child.element.kind == :tool_call_card
+ assert child.element.type == :widget
+ assert child.element.attributes.component.family == :row_and_artifact
+
+ assert child.element.attributes.tool_call == %{
+ tool_name: "Bash",
+ tool_kind: :bash,
+ target: "mix test",
+ summary: "Run the focused suite.",
+ status: :pending,
+ args: %{cmd: "mix test"},
+ expanded?: false
+ }
+
+ assert [%UnifiedIUR.Interaction{family: :command, intent: :expand_toggled}] =
+ child.element.attributes.interactions
+
+ assert :ok = UnifiedIUR.Validate.element(child.element)
+ end
+
test "returns structured conversion errors for invalid propose_new_doc_card payloads" do
ash_iur =
IUR.new(:screen,
@@ -393,6 +437,33 @@ defmodule AshUI.Rendering.IURAdapterTest do
assert error.message =~ "propose_new_doc_card :status must be one of"
end
+ test "returns structured conversion errors for invalid tool_call_card payloads" do
+ ash_iur =
+ IUR.new(:screen,
+ id: "tool-call-card-screen-invalid",
+ name: "tool_call_card_screen",
+ attributes: %{},
+ children: [
+ IUR.new(:tool_call_card,
+ id: "tool-call-invalid",
+ props: %{
+ "tool_name" => "Bash",
+ "tool_kind" => "bash",
+ "target" => "mix test",
+ "summary" => "Run tests",
+ "status" => "pending",
+ "args" => ["cmd", "mix test"]
+ }
+ )
+ ]
+ )
+
+ assert {:error, {:conversion_failed, %ArgumentError{} = error}} =
+ IURAdapter.to_canonical(ash_iur)
+
+ assert error.message =~ "tool_call_card :args must be a map"
+ end
+
test "routes workflow_progress_status_card kind through workflow_progress_and_status family" do
ash_iur =
IUR.new(:screen,
diff --git a/test/ash_ui/rendering/live_ui_adapter_test.exs b/test/ash_ui/rendering/live_ui_adapter_test.exs
index 0cbd6cb1..e380df8d 100644
--- a/test/ash_ui/rendering/live_ui_adapter_test.exs
+++ b/test/ash_ui/rendering/live_ui_adapter_test.exs
@@ -1241,6 +1241,48 @@ defmodule AshUI.Rendering.LiveUIAdapterTest do
end
end
+ describe "tool_call_card adapter dispatch" do
+ test "generates dedicated tool_call_card fallback markup" do
+ iur = %{
+ "type" => "tool_call_card",
+ "id" => "tool-call-card-adapter-test",
+ "props" => %{
+ "tool_call" => %{
+ "tool_name" => "Bash",
+ "tool_kind" => "bash",
+ "target" => "mix test",
+ "summary" => "Run the focused suite.",
+ "status" => "complete",
+ "args" => %{"cmd" => "mix test"},
+ "expanded?" => true,
+ "tool_result_summary" => %{
+ "event_id" => "result-1",
+ "status" => "complete",
+ "compact_output" => "Tests passed.",
+ "diff_summary" => "No source diff.",
+ "error?" => false
+ }
+ }
+ },
+ "children" => [],
+ "metadata" => %{}
+ }
+
+ {:ok, heex} = LiveUIAdapter.render(iur, force_fallback: true)
+
+ assert heex =~ "ash-tool-call-card"
+ assert heex =~ ~s(data-live-ui-widget="tool-call-card")
+ assert heex =~ ~s(data-tool-kind="bash")
+ assert heex =~ ~s(data-status="complete")
+ assert heex =~ "Bash"
+ assert heex =~ "mix test"
+ assert heex =~ "Run the focused suite."
+ assert heex =~ "ash-tool-call-card__args"
+ assert heex =~ "Tests passed."
+ assert heex =~ "No source diff."
+ end
+ end
+
describe "composer_query_preview adapter dispatch" do
test "generates dedicated composer_query_preview fallback markup" do
iur = %{