Packages

fnord

0.9.37

AI code archaeology

Current section

Files

Jump to
fnord lib ui.ex
Raw

lib/ui.ex

defmodule UI do
@moduledoc """
User interface functions for output, logging, and user interaction.
## Context Warnings for Interactive UI
Interactive UI functions (`confirm/1`, `choose/2`, `prompt/1`) can cause deadlocks
when called from certain contexts and must be wrapped appropriately.
### GenServer Callbacks
Use `UI.Queue.run_from_genserver/1` to prevent deadlocks:
def handle_call(:delete_item, _from, state) do
confirmed = UI.Queue.run_from_genserver(fn ->
UI.confirm("Delete this item?")
end)
# ...
end
### Services.Globals.Spawn.async and Spawned Processes
Use `UI.Queue.run_from_task/1` when tasks need to participate in an existing UI interaction:
task = Services.Globals.Spawn.async(fn ->
UI.Queue.run_from_task(fn ->
UI.confirm("Process this item?")
end)
end)
### Creating UI Components
Use `UI.interact/1` to group multiple UI operations into a single atomic component:
def confirm_with_details(item) do
UI.interact(fn ->
UI.info("Item details: \#{item.name}")
UI.puts("Size: #\{item.size}, Modified: #\{item.date}")
UI.confirm("Delete this item?")
end)
end
Non-interactive functions (`info/2`, `warn/2`, `error/2`, `puts/1`, `say/1`) are safe
to call directly from any context.
## Interactive vs Non-Interactive Functions
**Interactive (require context wrappers in GenServer/Task contexts):**
- `confirm/1` - waits for yes/no input
- `choose/2` - waits for selection
- `prompt/1` - waits for text input
**Non-interactive (safe to call directly from any context):**
- `info/2`, `warn/2`, `error/2`, `debug/2` - just output
- `puts/1`, `say/1` - just output
- `interact/1` - groups operations but doesn't itself interact
"""
require Logger
# ----------------------------------------------------------------------------
# Messaging
# ----------------------------------------------------------------------------
@doc """
Execute a function as a single interaction unit. All UI calls within the function
(puts, log, choose, prompt, etc.) will be treated as part of this interaction
and execute immediately without queuing.
This is useful for composite TUI components that combine multiple UI elements.
"""
def interact(fun) when is_function(fun, 0) do
output_module().interact(fun)
end
def say(msg) do
UI.flush()
msg
|> format_detail()
|> output_module().puts()
end
def puts(msg) do
output_module().puts(msg)
end
# ----------------------------------------------------------------------------
# Feedback messages from the LLM
# ----------------------------------------------------------------------------
def feedback(:info, name, msg) do
feedback(name, msg, :green_background, :green)
end
def feedback(:warn, name, msg) do
feedback(name, msg, :yellow_background, :yellow)
end
def feedback(:error, name, msg) do
feedback(name, msg, :red_background, :red)
end
def feedback(:debug, name, msg) do
feedback(name, msg, :cyan_background, :cyan)
end
def feedback_user(msg) do
feedback("You", msg, :blue_background, :black)
end
def feedback_assistant(name, msg) do
feedback(name, msg, :green_background, :black)
end
defp feedback(name, msg, label_codes, detail_codes) do
if coordinator_name?(name) do
marker = [:magenta, :bright, coordinator_speech_marker(), :reset]
name = [:bright, name, :reset]
msg = [:italic, detail_codes, msg, :reset]
formatted =
[marker, [" "], name, [" "], marker, [" "], msg]
|> Enum.concat()
|> IO.ANSI.format(colorize?())
output_module().log(:info, formatted)
else
# Pad inside the colored block on both sides so the background
# color fully covers the Tibetan ornaments. The glyphs have
# inconsistent cell-width reporting, and without the buffer the
# background paint bleeds off the edge of ༺ / ༻.
msg =
[
label_codes,
:bright,
"༺ ",
name,
" ༻ ",
:reset,
:italic,
detail_codes,
msg,
:reset
]
|> IO.ANSI.format(colorize?())
output_module().log(:info, msg)
end
end
# ----------------------------------------------------------------------------
# Step reporting and logging
# ----------------------------------------------------------------------------
def report_from(nil, msg), do: info(msg)
def report_from(name, msg) do
if coordinator_name?(name) do
marker = [:magenta, :bright, coordinator_report_marker(), :reset]
name = [:bright, name, :reset]
[marker, [" "], name, [" "], marker, [" "], [:cyan, msg, :reset]]
|> Enum.concat()
|> IO.ANSI.format(colorize?())
|> info()
else
IO.ANSI.format([:cyan, "⦑ #{name} ⦒ ", :reset, msg], colorize?())
|> info()
end
end
def report_from(nil, msg, detail), do: info(msg, detail)
def report_from(name, msg, detail) do
label =
if coordinator_name?(name) do
marker = [:magenta, :bright, coordinator_report_marker(), :reset]
marker ++ [" ", name, " "] ++ marker
else
[:cyan, "⦑ ", name, " ⦒", :reset]
end
detail = [
:green,
msg,
:reset,
": ",
:light_black,
clean_detail(detail),
:reset
]
output =
[label, [" "], detail]
|> Enum.concat()
|> IO.ANSI.format(colorize?())
output_module().log(:info, output)
end
def report_step(msg), do: info(msg)
def report_step(msg, detail), do: info(msg, detail)
def begin_step(msg) do
IO.ANSI.format([:green, "➤ ", msg, :reset], colorize?())
|> info()
end
def begin_step(msg, detail) do
output_module().log(
:info,
IO.ANSI.format(
[:green, "➤ ", msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
end
def end_step(msg) do
IO.ANSI.format([:yellow, "✓ ", msg, :reset], colorize?())
|> info()
end
def end_step(msg, detail) do
output_module().log(
:info,
IO.ANSI.format(
[:yellow, "✓ ", msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
end
def fail_step(msg) do
IO.ANSI.format([:red, "✗ ", msg, :reset], colorize?())
|> info()
end
def fail_step(msg, detail) do
output_module().log(
:info,
IO.ANSI.format(
[:red, "✗ ", msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
end
def end_step_background(msg) do
IO.ANSI.format([:light_black, "✓ ", msg, :reset], colorize?())
|> info()
end
def end_step_background(msg, detail) do
output_module().log(
:info,
IO.ANSI.format(
[:light_black, "✓ ", msg, :reset, ": ", :light_black, clean_detail(detail), :reset],
colorize?()
)
)
end
# Directly write to ensure visibility even if output is paused.
def printf_debug(item) do
Logger.debug(inspect(item, pretty: true))
Logger.flush()
item
end
def debug(msg) do
msg = IO.ANSI.format([:green, msg, :reset], colorize?())
output_module().log(:debug, msg)
end
def debug(msg, detail) do
msg =
IO.ANSI.format(
[:green, msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
output_module().log(:debug, msg)
end
def info(msg) do
output_module().log(:info, IO.ANSI.format([:green, msg, :reset], colorize?()))
end
def info(msg, detail) do
output_module().log(
:info,
IO.ANSI.format(
[:green, msg || "", :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
end
def warn(msg) do
output_module().log(:warning, IO.ANSI.format([:yellow, msg, :reset], colorize?()))
end
def warn(msg, detail) do
output_module().log(
:warning,
IO.ANSI.format(
[:yellow, msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
end
def error(msg) do
output_module().log(:error, IO.ANSI.format([:red, msg, :reset], colorize?()))
end
def error(msg, detail) do
output_module().log(
:error,
IO.ANSI.format([:red, msg, :reset, ": ", :cyan, clean_detail(detail), :reset], colorize?())
)
end
# Directly write to ensure visibility even if output is paused.
@spec fatal(binary) :: no_return()
def fatal(msg) do
case Services.Globals.get_env(:fnord, :test_no_halt, false) do
true ->
raise RuntimeError, message: msg
_ ->
Logger.error(IO.ANSI.format([:red, msg, :reset], colorize?()))
Logger.flush()
System.halt(1)
end
end
# Directly write to ensure visibility even if output is paused.
@spec fatal(binary, binary) :: no_return()
def fatal(msg, detail) do
case Services.Globals.get_env(:fnord, :test_no_halt, false) do
true ->
raise RuntimeError, message: "#{msg}: #{detail}"
_ ->
Logger.error(
IO.ANSI.format(
[:red, msg, :reset, ": ", :cyan, clean_detail(detail), :reset],
colorize?()
)
)
Logger.flush()
System.halt(1)
end
end
# Directly write to stderr to ensure visibility even if output is paused.
@spec warning_banner(binary) :: :ok
def warning_banner(msg) do
formatted =
IO.ANSI.format(
[
:red_background,
:black,
" <<< WARNING >>> #{msg} <<< WARNING >>> ",
:reset
],
colorize?()
)
IO.puts(:stderr, formatted)
UI.Tee.write([formatted, "\n"])
end
@spec log_usage(AI.Model.t(), non_neg_integer | map) :: :ok
def log_usage(model, usage) when is_integer(usage) do
safe_usage = max(usage, 0)
safe_context = max(model.context, 1)
percentage = Float.round(safe_usage / safe_context * 100, 2)
str_usage = Util.format_number(safe_usage)
str_context = Util.format_number(safe_context)
info("Context window usage", "#{percentage}% (#{str_usage} / #{str_context} tokens)")
end
def log_usage(model, %{"total_tokens" => total_tokens} = _usage) do
log_usage(model, total_tokens)
end
def log_usage(model, %{total_tokens: total_tokens} = _usage) do
log_usage(model, total_tokens)
end
@spec italicize(binary) :: iodata
def italicize(text) do
IO.ANSI.format([:italic, text, :reset], colorize?())
end
@spec bold(binary) :: iodata
def bold(text) do
IO.ANSI.format([:bright, text, :reset], colorize?())
end
@spec mute(binary) :: iodata
def mute(text) do
IO.ANSI.format([:light_black, text, :reset], colorize?())
end
# ----------------------------------------------------------------------------
# TUI/Animated elements
# ----------------------------------------------------------------------------
def spin(processing, func) do
if quiet?() do
begin_step(processing)
{msg, result} = func.()
end_step(msg)
result
else
Spinner.run(func, processing)
end
end
def progress_bar_start(name, label, total) do
if !quiet?() do
Owl.ProgressBar.start(
id: name,
label: label,
total: total,
timer: true,
absolute_values: true
)
end
end
# Non-blocking tick. Increments the counter and lets Owl's LiveScreen
# pick up the change on its own 60ms render cycle. Previously this
# function also called Owl.LiveScreen.await_render/0 after each inc,
# which blocked the caller until the next render tick - effectively
# serializing the entire loop on Owl's refresh clock and erasing the
# concurrency of any async_stream reducer that called it per item.
# If you genuinely need to flush the screen (e.g. before exiting),
# call Owl.LiveScreen.await_render/0 once at that point, not per tick.
def progress_bar_update(name) do
if !quiet?() do
Owl.ProgressBar.inc(id: name)
end
end
def async_stream(enumerable, fun, label \\ "Working", options \\ []) do
progress_bar_start(:async_stream, label, Enum.count(enumerable))
enumerable
|> Util.async_stream(
fn item ->
result = fun.(item)
progress_bar_update(:async_stream)
result
end,
options
)
end
# ----------------------------------------------------------------------------
# Interactive prompts
# ----------------------------------------------------------------------------
def choose(label, options, timeout_ms, default) do
if UI.is_tty?() && !UI.quiet?() do
output_module().choose(label, options, timeout_ms, default)
else
{:error, :no_tty}
end
end
def choose(label, options) do
if UI.is_tty?() && !UI.quiet?() do
output_module().choose(label, options)
else
{:error, :no_tty}
end
end
def choose_multi(label, options), do: choose_multi(label, options, [])
def choose_multi(label, options, owl_opts) do
if UI.is_tty?() && !UI.quiet?() do
output_module().choose_multi(label, options, owl_opts)
else
{:error, :no_tty}
end
end
def prompt(prompt, owl_opts \\ []) do
if UI.is_tty?() && !UI.quiet?() do
owl_opts = Keyword.put(owl_opts, :label, prompt)
output_module().prompt(prompt, owl_opts)
else
{:error, :no_tty}
end
end
@spec confirm(binary) :: boolean
def confirm(msg), do: confirm(msg, false)
@spec confirm(binary, boolean) :: boolean
def confirm(msg, default) do
output_module().confirm(msg, default)
end
def newline do
unless UI.quiet?() do
output_module().newline()
end
end
def box(contents, opts) do
unless UI.quiet?() do
output_module().newline()
output_module().box(contents, opts)
end
end
# ----------------------------------------------------------------------------
# Editor
# ----------------------------------------------------------------------------
@doc """
Open `content` in the user's editor and return the edited text.
Respects `ELIXIR_EDITOR`, then `EDITOR`, falling back to `vi`.
Uses a Port with `:nouse_stdio` so the editor inherits the real terminal
file descriptors - this avoids the hang that `System.shell` causes with
TUI editors like vim/nvim (whose stdin would otherwise be BEAM's pipe).
"""
@spec open_in_editor(String.t(), keyword()) :: String.t()
def open_in_editor(content, opts \\ []) do
editor =
Util.Env.get_env("ELIXIR_EDITOR") ||
Util.Env.get_env("EDITOR") ||
"vi"
extension = Keyword.get(opts, :extension, ".txt")
tmpfile = Path.join(System.tmp_dir!(), "fnord-#{:rand.uniform(1_000_000)}#{extension}")
try do
File.write!(tmpfile, content)
# Split the editor string to handle multi-word values like "code --wait".
# Spawn the executable directly with the temp file as an argument rather
# than interpolating into a shell command, avoiding breakage on paths
# with spaces and shell injection concerns.
[editor_cmd | editor_args] = String.split(editor)
port =
Port.open(
{:spawn_executable, System.find_executable(editor_cmd) || editor_cmd},
[:nouse_stdio, :exit_status, args: editor_args ++ [tmpfile]]
)
receive do
{^port, {:exit_status, 0}} -> :ok
{^port, {:exit_status, n}} -> raise "Editor exited with status #{n}"
end
File.read!(tmpfile)
after
File.rm(tmpfile)
end
end
# ----------------------------------------------------------------------------
# Helper functions
# ----------------------------------------------------------------------------
def flush, do: output_module().flush()
defp output_module do
Services.Globals.get_env(:fnord, :ui_output, UI.Output.Production)
end
def quiet?() do
Services.Globals.get_env(:fnord, :quiet) == true
end
def is_tty? do
# Tree-scoped override so tests can dictate TTY-ness without mocking the
# UI module itself; :auto probes the real device.
case Services.Globals.get_env(:fnord, :is_tty, :auto) do
:auto -> :prim_tty.isatty(:stderr) == true
value -> value == true
end
end
# Checks stdout specifically; used to suppress the external formatter when
# stdout is piped (e.g. `| tee`), since formatted output (e.g. box-drawing
# characters) is not useful in a log file.
def stdout_tty? do
case Services.Globals.get_env(:fnord, :stdout_tty, :auto) do
:auto -> :prim_tty.isatty(:stdout) == true
value -> value == true
end
end
def colorize?, do: is_tty?() && !quiet?()
@doc """
Formats a string through the external FNORD_FORMATTER command, if configured.
Skipped when quiet mode is active or stdout is not a TTY.
"""
defdelegate format(input), to: UI.Formatter, as: :format_output
# Determine whether the provided name matches the coordinator's name.
# We consider the coordinator to be the default name, or the name currently
# associated with this process via Services.NamePool.
defp coordinator_name?(nil), do: false
defp coordinator_name?(name) when is_binary(name) do
default = Services.NamePool.default_name()
if name == default do
true
else
case Services.NamePool.get_name_by_pid(self()) do
{:ok, pid_name} when is_binary(pid_name) -> pid_name == name
_ -> false
end
end
end
# Return the visual marker for coordinator messages. Use 'ƒ' when color is
# available, otherwise fall back to an ASCII label for maximal compatibility.
defp coordinator_report_marker() do
if colorize?() do
"⚒"
else
"*"
end
end
defp coordinator_speech_marker() do
if colorize?() do
"☊"
else
"@"
end
end
defp format_detail(content) when is_binary(content) do
content |> UI.format()
end
defp format_detail(content) when is_list(content) do
content |> IO.ANSI.format(colorize?())
end
defp format_detail(content) do
# Fallback for other types - convert to string and treat as markdown
content |> to_string() |> UI.format()
end
def clean_detail(nil), do: ""
def clean_detail(detail) do
if iodata?(detail) do
detail
else
inspect(detail, pretty: true, limit: :infinity)
end
|> IO.ANSI.format(colorize?())
|> IO.iodata_to_binary()
|> String.replace_invalid("�")
|> String.trim()
|> then(fn str ->
if String.contains?(str, "\n") do
# If there are multiple lines, prefix with an empty line to ensure the
# string is displayed correctly.
"\n" <> str
else
str
end
end)
end
def iodata?(term) when is_binary(term), do: true
def iodata?(term) when is_integer(term) and term in 0..255, do: true
def iodata?([]), do: true
def iodata?([head | tail]), do: iodata?(head) and iodata_tail?(tail)
def iodata?(_), do: false
defp iodata_tail?(tail) when is_list(tail), do: iodata?(tail)
defp iodata_tail?(tail) when is_binary(tail), do: true
defp iodata_tail?(_), do: false
end