defmodule Vibe.TUI.Widgets.Message do @moduledoc "TUI widget: chat message bubble with role styling." @behaviour Vibe.TUI.Widget alias Vibe.Terminal.{Markdown, Theme} alias Vibe.TUI.Widget alias Vibe.TUI.Widgets.Loader alias Vibe.UI.Error @impl true def render(%{props: %{role: :user, text: text} = props}, width, theme) do safe_render(width, theme, fn -> render_user(text, props, width, theme) end) end def render(%{props: %{error: error}}, width, theme) when not is_nil(error) do safe_render(width, theme, fn -> error |> Error.text() |> Markdown.render(max(width - 4, 1), theme) |> prefix_first_line(Theme.fg(theme, :error, Theme.symbol(theme, :error_icon) <> " ")) |> render_block_lines(width, theme, :tool_error_bg, :error) end) end def render(%{props: %{role: :assistant} = props}, width, theme) do safe_render(width, theme, fn -> render_assistant( Map.get(props, :text), width, theme, Map.get(props, :loader_phase, 0), Map.get(props, :loader_label) ) end) end def render(%{props: %{role: :system} = props}, width, theme) do safe_render(width, theme, fn -> render_system(props, width, theme) end) end def render(%{props: %{role: :subagent} = props}, width, theme) do safe_render(width, theme, fn -> render_subagent(props, width, theme) end) end def render(%{props: %{text: text} = props}, width, theme) do safe_render(width, theme, fn -> render_user(text, props, width, theme) end) end defp render_user(text, props, width, theme) do text |> user_text_with_attachments(props) |> render_plain_block(width, theme, :user_message_bg, :user_message_text) end defp system_color(:success), do: :success defp system_color(:warning), do: :warning defp system_color(:error), do: :error defp system_color(_level), do: :accent defp user_text_with_attachments(text, props) do case Map.get(props, :image_count, 0) do count when is_integer(count) and count > 0 -> label = if count == 1, do: "image", else: "images" [to_string(text), "\n[", Integer.to_string(count), " ", label, " attached]"] _count -> text end end defp render_system(props, width, theme) do text = props |> Map.get(:text, "") |> to_string() icon = Theme.fg(theme, system_color(Map.get(props, :level)), "◆") [[icon, " ", Theme.fg(theme, :muted, text)]] |> render_block_lines(width, theme, :assistant_message_bg, :muted) end defp render_subagent(props, width, theme) do title = subagent_title(props, theme) details = subagent_details(props) [title | details] |> render_block_lines(width, theme, :tool_pending_bg, :text) end defp subagent_title(props, theme) do lifecycle = Map.get(props, :lifecycle, :started) status = Map.get(props, :status) role = props |> Map.get(:role_name, Map.get(props, :role_label, Map.get(props, :role))) |> role_name() icon = Theme.fg(theme, :tool_icon, Theme.symbol(theme, :tool_icon)) label = if lifecycle == :finished, do: "finished", else: "started" suffix = if status, do: " · #{status}", else: "" [icon, " subagent ", role, " ", label, suffix] end defp subagent_details(props) do [ detail_line("task", Map.get(props, :task)), detail_line("session", Map.get(props, :child_session_id)), detail_line("attach", attach_command(Map.get(props, :child_session_id))), detail_line("error", Map.get(props, :error)) ] |> Enum.reject(&is_nil/1) end defp detail_line(_label, nil), do: nil defp detail_line(_label, ""), do: nil defp detail_line(label, value), do: [" ", label, ": ", to_string(value)] defp attach_command(session_id) when is_binary(session_id), do: "vibe a #{session_id}" defp attach_command(_session_id), do: nil defp role_name(:subagent), do: "worker" defp role_name(nil), do: "worker" defp role_name(role), do: to_string(role) defp render_assistant(text, width, theme, loader_phase, loader_label) do text = text |> to_string() |> String.trim() if text == "" do Loader.render( %{props: %{label: loader_label || "Thinking", phase: loader_phase}}, width, theme ) else text |> Markdown.render(max(width - 4, 1), theme) |> render_block_lines(width, theme, :assistant_message_bg, :assistant_message_text) end end defp render_plain_block(text, width, theme, bg_key, fg_key) do text |> to_string() |> String.split("\n") |> Enum.flat_map(&Widget.wrap(&1, max(width - 4, 1))) |> render_block_lines(width, theme, bg_key, fg_key) end defp render_block_lines(lines, width, theme, bg_key, fg_key) do Widget.block_lines(lines, width, theme, bg_key, fg: fg_key, padding_left: 2) end defp safe_render(width, theme, fun) do fun.() rescue error -> render_failure(width, theme, Exception.format(:error, error, __STACKTRACE__)) catch kind, reason -> render_failure(width, theme, Exception.format(kind, reason, __STACKTRACE__)) end defp render_failure(width, theme, error) do error |> String.split("\n") |> Enum.flat_map(&Widget.wrap(Theme.fg(theme, :error, &1), max(width - 4, 1))) |> prefix_first_line(Theme.fg(theme, :error, "RENDER ERROR ")) |> render_block_lines(width, theme, :tool_error_bg, :error) end defp prefix_first_line([first | rest], prefix), do: [[prefix, first] | rest] end