Skip to main content

lib/omni/ui/tools_ui.ex

defmodule Omni.UI.ToolsUI do
  @moduledoc """
  Custom tool-use components for the built-in tools.

  Register these in the `:tool_components` map passed to `init_session/2`
  so that `content_block/1` dispatches to them instead of the default
  `tool_use/1` renderer:

      init_session(socket,
        tool_components: %{
          "files" => &Omni.UI.ToolsUI.files_tool_use/1,
          "repl"  => &Omni.UI.ToolsUI.repl_tool_use/1
        },
        ...
      )

  `Omni.UI.AgentLive` registers both by default.
  """

  use Phoenix.Component
  import Omni.UI.CoreUI, only: [expandable: 1]

  import Omni.UI.Helpers,
    only: [highlight_code: 1, highlight_code: 2, format_json: 1, format_tool_result: 1]

  alias Omni.UI.ChatUI

  # ── Files tool ────────────────────────────────────────────────────

  @doc """
  Renders a ToolUse content block for the files tool.

  Delegates to `Omni.UI.ChatUI.tool_use/1` and fills its `:aside` slot
  with command-specific content. Receives the normalised tool-use assigns
  map from `content_block/1`: `@tool_use`, `@tool_result`, `@streaming`.
  """
  attr :tool_use, Omni.Content.ToolUse, required: true
  attr :tool_result, Omni.Content.ToolResult, default: nil
  attr :streaming, :boolean, default: false

  def files_tool_use(assigns) do
    ~H"""
    <ChatUI.tool_use tool_use={@tool_use} tool_result={@tool_result} streaming={@streaming}>
      <:aside :if={@tool_result}>
        <.files_aside
          command={@tool_use.input["command"]}
          filename={@tool_use.input["id"]} />
      </:aside>
    </ChatUI.tool_use>
    """
  end

  attr :command, :string, required: true
  attr :filename, :string, default: nil

  defp files_aside(%{command: command} = assigns) when command in ["write", "patch"] do
    ~H"""
    <button
      class={[
        "inline-flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-sm border transition-colors cursor-pointer",
        "text-omni-text-1 border-omni-border-3 hover:text-omni-accent-1 hover:bg-omni-accent-2/5 hover:border-omni-accent-2"
      ]}
      phx-click="open_file"
      phx-value-filename={@filename}>
      <Lucideicons.square_arrow_out_up_right class="size-4" />
      <span class="font-medium">{@filename}</span>
    </button>
    """
  end

  defp files_aside(%{command: "list"} = assigns) do
    ~H"""
    <div class="text-xs text-omni-text-4">
      Listed files
    </div>
    """
  end

  defp files_aside(%{command: "read"} = assigns) do
    ~H"""
    <div class="text-xs text-omni-text-4">
      Read
      <span class="font-medium">{@filename}</span>
    </div>
    """
  end

  defp files_aside(%{command: "delete"} = assigns) do
    ~H"""
    <div class="text-xs text-omni-text-4">
      Deleted
      <span class="font-medium">{@filename}</span>
    </div>
    """
  end

  # ── REPL tool ─────────────────────────────────────────────────────

  @doc """
  Renders a ToolUse content block for the REPL tool.

  Receives the normalised tool-use assigns map from `content_block/1`:
  `@tool_use`, `@tool_result`, `@streaming`.
  """
  attr :tool_use, Omni.Content.ToolUse, required: true
  attr :tool_result, Omni.Content.ToolResult, default: nil
  attr :streaming, :boolean, default: false

  def repl_tool_use(assigns) do
    assigns =
      assigns
      |> assign(:title, Map.get(assigns.tool_use.input, "title", "Running code"))
      |> assign(:code, Map.get(assigns.tool_use.input, "code"))

    ~H"""
    <.expandable label={@title}>
      <:icon>
        <Lucideicons.terminal class="size-4 text-omni-text-4" />
      </:icon>

      <:status :if={@streaming}>
        <ChatUI.busy_anim />
      </:status>

      <:status :if={not @streaming and @tool_result}>
        <Lucideicons.check
          :if={not @tool_result.is_error}
          class="size-3 text-green-500" />
        <Lucideicons.circle_x
          :if={@tool_result.is_error}
          class="size-4 text-red-500" />
      </:status>

      <div
        class={[
          "space-y-3",
          "[&_pre]:m-0! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:max-h-48 [&_pre]:rounded",
          "[&_pre]:text-xs [&_pre]:overflow-auto"
        ]}>
        <div>
          <span class="block mb-1 text-xs text-omni-text-2">{if(@code, do: "Code", else: "Input")}:</span>
          <%= if @code do %>
            <%= highlight_code(@code, "elixir") %>
          <% else %>
            <%= highlight_code(format_json(@tool_use.input), "json") %>
          <% end %>
        </div>

        <div
          :if={@tool_result}
          class={if(@tool_result.is_error, do: "[&_pre]:ring-2 [&_pre]:ring-offset-2 [&_pre]:ring-red-500")}>
          <span class="block mb-1 text-xs text-omni-text-2">Output:</span>
          <%= highlight_code(format_tool_result(@tool_result)) %>
        </div>
      </div>
    </.expandable>
    """
  end
end