Skip to main content

priv/templates/components/agentix_components.ex

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