Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/driver.ex
defmodule Raxol.Terminal.Driver do
@moduledoc """
Handles raw terminal input/output and event generation.
Responsibilities:
- Setting terminal mode (raw, echo)
- Reading input events via termbox2_nif NIF
- Parsing input events into `Raxol.Core.Events.Event` structs
- Detecting terminal resize events
- Sending parsed events to the `Dispatcher`
- Restoring terminal state on exit
"""
alias Raxol.Core.Runtime.Log
use Raxol.Core.Behaviours.BaseManager
require Logger
require Raxol.Core.Runtime.Log
# Import Bitwise for bitwise operations
# import Bitwise
alias Raxol.Core.Events.Event
alias Raxol.Terminal.ANSI.InputParser
alias Raxol.Terminal.IOTerminal
# Check if termbox2_nif is available at compile time
@termbox2_available Code.ensure_loaded?(:termbox2_nif)
# Add import for real_tty? from TerminalUtils
import Raxol.Terminal.TerminalUtils, only: [real_tty?: 0]
# Constants for retry logic
@max_init_retries 3
# ms
@init_retry_delay 1000
# Allow nil initially
@type dispatcher_pid :: pid() | nil
@type original_stty :: String.t()
@type termbox_state :: :uninitialized | :initialized | :failed
defmodule State do
@moduledoc false
defstruct dispatcher_pid: nil,
original_stty: nil,
termbox_state: :uninitialized,
init_retries: 0,
io_terminal_state: nil
end
# --- Public API ---
@doc """
Returns the current terminal backend being used.
## Examples
iex> Raxol.Terminal.Driver.backend()
:termbox2_nif
iex> Raxol.Terminal.Driver.backend()
:io_terminal
"""
# The spec covers both possible return values across platforms.
# On any given compilation, only one branch is reachable due to
# @termbox2_available being a compile-time constant.
@dialyzer {:nowarn_function, backend: 0}
@spec backend() :: :termbox2_nif | :io_terminal
def backend do
if @termbox2_available, do: :termbox2_nif, else: :io_terminal
end
# BaseManager provides start_link/1 and start_link/2 automatically
# We can override if needed but the dispatcher_pid is passed as init argument
# --- BaseManager Callbacks ---
@impl true
def init_manager(opts) do
# Extract dispatcher_pid from opts - handle both keyword list and raw value
dispatcher_pid = extract_dispatcher_pid(opts)
Raxol.Core.Runtime.Log.info(
"[#{__MODULE__}] init called with dispatcher: #{inspect(dispatcher_pid)}"
)
# Get original terminal settings using Erlang IO (no subprocess needed)
output =
case {:io.rows(), :io.columns()} do
{{:ok, rows}, {:ok, cols}} -> "#{rows} #{cols}"
_ -> "80 24"
end
state = %State{
dispatcher_pid: dispatcher_pid,
original_stty: output,
termbox_state: :uninitialized,
init_retries: 0
}
# Initialize terminal in raw mode only if attached to a TTY
case {Mix.env(), real_tty?(), dispatcher_pid} do
{:test, _, nil} ->
Raxol.Core.Runtime.Log.info(
"[Driver] Test environment detected, sending driver_ready event"
)
Raxol.Core.Runtime.Log.warning_with_context(
"[Driver] No dispatcher_pid provided, skipping driver_ready and initial resize event",
%{}
)
state = %{state | termbox_state: :initialized}
{:ok, state}
{:test, _, pid} ->
Raxol.Core.Runtime.Log.info(
"[Driver] Test environment detected, sending driver_ready event"
)
send(pid, {:driver_ready, self()})
Raxol.Core.Runtime.Log.info(
"[Driver] Sending initial resize event to dispatcher_pid: #{inspect(pid)}"
)
send_initial_resize_event(pid)
state = %{state | termbox_state: :initialized}
{:ok, state}
{_, true, _} ->
Raxol.Core.Runtime.Log.info(
"[TerminalDriver] TTY detected, initializing ANSI terminal..."
)
# Suppress Logger console output so it doesn't corrupt the TUI
Logger.configure(level: :none)
# Enter alternate screen, hide cursor
IO.write("\e[?1049h\e[?25l")
# Enable terminal modes: all-motion mouse, SGR encoding, focus reporting, bracketed paste
IO.write("\e[?1003h\e[?1006h\e[?1004h\e[?2004h")
# Configure Erlang IO for raw-ish input (no echo, no line buffering)
:io.setopts(:standard_io, binary: true, echo: false)
# Send initial resize event if we have a dispatcher
if dispatcher_pid, do: send_initial_resize_event(dispatcher_pid)
# Start input reader process
input_reader_pid = start_input_reader(self())
state = %{
state
| termbox_state: :initialized,
io_terminal_state: %{input_reader: input_reader_pid}
}
{:ok, state}
{_, false, _} ->
Raxol.Core.Runtime.Log.warning_with_context(
"Not attached to a TTY. Skipping Termbox2Nif.tb_init(). Terminal features will be disabled.",
%{}
)
{:ok, state}
end
end
# --- BaseManager handle_info callbacks ---
@impl true
def handle_manager_info(:retry_init, %{init_retries: retries} = state)
when retries < @max_init_retries do
case initialize_termbox() do
:ok ->
Raxol.Core.Runtime.Log.info("Successfully initialized termbox on retry")
{:noreply, %{state | termbox_state: :initialized}}
{:error, reason} ->
Raxol.Core.Runtime.Log.error(
"Failed to initialize termbox on retry #{retries + 1}: #{inspect(reason)}"
)
Process.send_after(self(), :retry_init, @init_retry_delay)
{:noreply, %{state | init_retries: retries + 1}}
end
end
@impl true
def handle_manager_info(:retry_init, state) do
Raxol.Core.Runtime.Log.error(
"Failed to initialize termbox after #{@max_init_retries} attempts. Terminal features will be disabled."
)
{:noreply, state}
end
@impl true
def handle_manager_info(
{:termbox_event, event_map},
%{termbox_state: :initialized, dispatcher_pid: dispatcher_pid} = state
) do
Raxol.Core.Runtime.Log.debug(
"Received termbox event: #{inspect(event_map)}"
)
case translate_termbox_event(event_map) do
{:ok, %Event{} = event} ->
# Only send if dispatcher_pid is known
case dispatcher_pid do
nil -> :ok
pid -> send_event_to_dispatcher(pid, event)
end
{:noreply, state}
:ignore ->
# Event type we don't care about
Raxol.Core.Runtime.Log.debug(
"[Driver] Ignoring termbox event: #{inspect(event_map)}"
)
{:noreply, state}
{:error, reason} ->
Raxol.Core.Runtime.Log.warning_with_context(
"Failed to translate termbox event: #{inspect(reason)}. Event: #{inspect(event_map)}",
%{}
)
{:noreply, state}
end
end
@impl true
def handle_manager_info({:termbox_event, _event_map}, state) do
# Ignore events if termbox is not initialized
{:noreply, state}
end
@impl true
def handle_manager_info({:termbox_error, reason}, state) do
Raxol.Core.Runtime.Log.error(
"Received termbox error: #{inspect(reason)}. Attempting recovery..."
)
case state.termbox_state do
:initialized -> handle_termbox_recovery(reason, state)
_ -> {:stop, {:termbox_error, reason}, state}
end
end
@impl true
def handle_manager_info({:register_dispatcher, pid}, state)
when is_pid(pid) do
Raxol.Core.Runtime.Log.info("Registering dispatcher PID: #{inspect(pid)}")
# Send initial size event now that we have the PID
send_initial_resize_event(pid)
{:noreply, %{state | dispatcher_pid: pid}}
end
@impl true
def handle_manager_info(
{:test_input, input_data},
%{dispatcher_pid: nil} = state
) do
Raxol.Core.Runtime.Log.warning_with_context(
"Received test input before dispatcher registration: #{inspect(input_data)}",
%{}
)
{:noreply, state}
end
@impl true
def handle_manager_info({:test_input, input_data}, state) do
# Construct a basic event. Tests might need more specific event types later.
# We need to parse the input_data into something the MockApp expects.
Raxol.Core.Runtime.Log.debug(
"[TerminalDriver.handle_cast - :test_input] Received input_data: #{inspect(input_data)}, state: #{inspect(state)}"
)
event = parse_test_input(input_data)
Raxol.Core.Runtime.Log.debug(
"[TerminalDriver.handle_cast - :test_input] Parsed event: #{inspect(event)}"
)
Raxol.Core.Runtime.Log.debug(
"[TEST] Dispatching simulated event: #{inspect(event)}"
)
GenServer.cast(state.dispatcher_pid, {:dispatch, event})
{:noreply, state}
end
@impl true
def handle_manager_info({:EXIT, _pid, _reason}, state) do
{:noreply, state}
end
@impl true
def handle_manager_info({:raw_input, data}, state) when is_binary(data) do
events = InputParser.parse(data)
Enum.each(events, fn event ->
case state.dispatcher_pid do
nil -> :ok
pid -> send_event_to_dispatcher(pid, event)
end
end)
{:noreply, state}
end
@impl true
def handle_manager_info(unhandled_message, state) do
Raxol.Core.Runtime.Log.warning_with_context(
"#{__MODULE__} received unhandled message: #{inspect(unhandled_message)}",
%{}
)
{:noreply, state}
end
# Forward cast messages to handle_info for test_input
@impl true
def handle_manager_cast({:test_input, input_data}, state) do
handle_manager_info({:test_input, input_data}, state)
end
# Private helper to extract dispatcher_pid from init opts
defp extract_dispatcher_pid(opts) when is_list(opts) do
Keyword.get(opts, :dispatcher_pid)
end
defp extract_dispatcher_pid(pid) when is_pid(pid), do: pid
defp extract_dispatcher_pid(_), do: nil
defp handle_termbox_recovery(reason, state) do
case terminate_termbox() do
:ok ->
case initialize_termbox() do
:ok ->
Raxol.Core.Runtime.Log.info(
"Successfully recovered from termbox error"
)
{:noreply, state}
{:error, init_reason} ->
Raxol.Core.Runtime.Log.error(
"Failed to recover from termbox error: #{inspect(init_reason)}"
)
{:stop, {:termbox_error, reason}, state}
end
_ ->
{:stop, {:termbox_error, reason}, state}
end
end
def terminate(_reason, %{termbox_state: :initialized} = state) do
Raxol.Core.Runtime.Log.info("Terminal Driver terminating.")
# Kill input reader if running
case get_in(state, [
Access.key(:io_terminal_state),
Access.key(:input_reader)
]) do
pid when is_pid(pid) -> Process.exit(pid, :shutdown)
_ -> :ok
end
# Only attempt shutdown if not in test environment
_ =
case {Mix.env(), real_tty?()} do
{:test, _} ->
:ok
{_, false} ->
:ok
{_, true} ->
# Disable terminal modes before restoring
IO.write("\e[?1003l\e[?1006l\e[?1004l\e[?2004l")
# Restore terminal: show cursor, leave alternate screen
IO.write("\e[?25h\e[?1049l")
:io.setopts(:standard_io, echo: true)
# Restore Logger output
Logger.configure(level: :debug)
:ok
end
:ok
end
def terminate(_reason, _state) do
Raxol.Core.Runtime.Log.info(
"Terminal Driver terminating (not initialized)."
)
:ok
end
@doc """
Processes a terminal title change event.
"""
def process_title_change(title, state) when is_binary(title) do
_ =
case {Mix.env(), real_tty?()} do
{:test, _} ->
:ok
{_, false} ->
:ok
{_, true} ->
_ =
if @termbox2_available do
apply(:termbox2_nif, :tb_set_title, [title])
else
0
end
end
{:noreply, state}
end
@doc """
Processes a terminal position change event.
"""
def process_position_change(x, y, state)
when is_integer(x) and is_integer(y) do
_ =
case {Mix.env(), real_tty?()} do
{:test, _} ->
:ok
{_, false} ->
:ok
{_, true} ->
_ =
if @termbox2_available do
apply(:termbox2_nif, :tb_set_position, [x, y])
else
0
end
end
{:noreply, state}
end
# --- Input Reader ---
defp start_input_reader(driver_pid) do
spawn_link(fn -> input_read_loop(driver_pid) end)
end
defp input_read_loop(driver_pid) do
# Use :io.get_chars for raw single-character reads
case :io.get_chars(:standard_io, ~c"", 1) do
:eof ->
:ok
{:error, _} ->
:ok
data when is_binary(data) ->
# If we got an escape byte, try to read the rest of the sequence
full_data =
if data == <<27>> do
read_escape_sequence(data)
else
data
end
send(driver_pid, {:raw_input, full_data})
input_read_loop(driver_pid)
data when is_list(data) ->
# :io.get_chars may return charlist
send(driver_pid, {:raw_input, IO.chardata_to_string(data)})
input_read_loop(driver_pid)
_ ->
input_read_loop(driver_pid)
end
end
defp read_escape_sequence(escape_byte) do
# Brief pause to let escape sequence bytes arrive
Process.sleep(2)
# Try to read more bytes (non-blocking via small timeout)
# Buffer size 32 to accommodate SGR mouse sequences (12+ bytes)
task =
Task.async(fn ->
case :io.get_chars(:standard_io, ~c"", 32) do
data when is_binary(data) -> data
data when is_list(data) -> IO.chardata_to_string(data)
_ -> ""
end
end)
case Task.yield(task, 100) do
{:ok, more} when is_binary(more) and byte_size(more) > 0 ->
escape_byte <> more
_ ->
Task.shutdown(task, :brutal_kill)
escape_byte
end
end
# --- Private Helpers ---
# defp init_test_environment(state, dispatcher_pid) do
# Raxol.Core.Runtime.Log.info(
# "[Driver] Test environment detected, sending driver_ready event"
# )
#
# case dispatcher_pid do
# nil ->
# Raxol.Core.Runtime.Log.warning_with_context(
# "[Driver] No dispatcher_pid provided, skipping driver_ready and initial resize event",
# %{}
# )
#
# pid ->
# send(pid, {:driver_ready, self()})
# # Send initial resize event for test environment
# Raxol.Core.Runtime.Log.info(
# "[Driver] Sending initial resize event to dispatcher_pid: #{inspect(pid)}"
# )
#
# send_initial_resize_event(pid)
# end
#
# # In test mode, set termbox_state to :initialized so we can handle test events
# state = %{state | termbox_state: :initialized}
# {:ok, state}
# end
# defp init_production_environment(state) do
# case real_tty?() do
# true ->
# Raxol.Core.Runtime.Log.debug(
# "[TerminalDriver] TTY detected, calling Termbox2Nif.tb_init()..."
# )
#
# _ = call_termbox_init()
#
# false ->
# Raxol.Core.Runtime.Log.warning_with_context(
# "Not attached to TTY. Terminal features disabled.",
# %{}
# )
# end
#
# {:ok, state}
# end
defp send_event_to_dispatcher(dispatcher_pid, event) do
case Mix.env() do
:test ->
Raxol.Core.Runtime.Log.debug(
"[Driver] Sending event in test mode: #{inspect(event)} to #{inspect(dispatcher_pid)}"
)
send(dispatcher_pid, {:"$gen_cast", {:dispatch, event}})
_ ->
GenServer.cast(dispatcher_pid, {:dispatch, event})
end
end
defp call_termbox_init do
if @termbox2_available do
:termbox2_nif.tb_init()
else
0
end
end
defp terminate_termbox do
if @termbox2_available do
:termbox2_nif.tb_shutdown()
else
# Shutdown IOTerminal if it was initialized
IOTerminal.shutdown()
0
end
end
# defp apply_termbox_function(function, args) do
# case @termbox2_available do
# true -> apply(:termbox2_nif, function, args)
# false -> :ok
# end
# end
defp get_termbox_width do
if @termbox2_available do
:termbox2_nif.tb_width()
else
# Use IOTerminal for size detection
case IOTerminal.get_terminal_size() do
{:ok, {width, _height}} -> width
_ -> 80
end
end
end
defp get_termbox_height do
if @termbox2_available do
:termbox2_nif.tb_height()
else
# Use IOTerminal for size detection
case IOTerminal.get_terminal_size() do
{:ok, {_width, height}} -> height
_ -> 24
end
end
end
defp initialize_termbox do
case call_termbox_init() do
0 ->
:ok
-1 ->
{:error, :init_failed}
# NIF only returns 0 or -1
end
end
defp get_terminal_size do
determine_terminal_size()
end
defp determine_terminal_size do
case {Mix.env(), real_tty?()} do
{:test, _} -> {:ok, 80, 24}
{_, true} -> get_termbox_size()
{_, false} -> stty_size_fallback()
end
end
defp get_termbox_size do
Raxol.Core.ErrorHandling.safe_call(fn ->
width = get_termbox_width()
height = get_termbox_height()
case width > 0 and height > 0 do
true -> {:ok, width, height}
false -> stty_size_fallback()
end
end)
|> case do
{:ok, result} -> result
{:error, _reason} -> stty_size_fallback()
end
end
defp stty_size_fallback do
case {:io.columns(), :io.rows()} do
{{:ok, cols}, {:ok, rows}} ->
{:ok, cols, rows}
_ ->
{:ok, 80, 24}
end
end
defp send_initial_resize_event(dispatcher_pid) do
# Keep this as it provides an immediate size on startup
{:ok, width, height} = get_terminal_size()
Raxol.Core.Runtime.Log.info("Initial terminal size: #{width}x#{height}")
event = %Event{type: :resize, data: %{width: width, height: height}}
# In test mode, send directly to the test process
case Mix.env() do
:test ->
Raxol.Core.Runtime.Log.info(
"[Driver] Sending resize event in test mode: #{inspect(event)}"
)
send(dispatcher_pid, {:"$gen_cast", {:dispatch, event}})
_ ->
GenServer.cast(dispatcher_pid, {:dispatch, event})
end
end
# --- Event translation from rrex_termbox v2.0.1 NIF ---
defp translate_termbox_event(event_map) do
case Raxol.Core.ErrorHandling.safe_call(fn ->
translate_event_map(event_map)
end) do
{:ok, result} -> result
{:error, reason} -> {:error, reason}
end
end
# Helper for key translation
defp translate_key(key_code, char_code, mod_code) do
# Actual implementation based on test simulations and potential NIF values
# Modifiers: Shift=1, Ctrl=2, Alt=4, Meta=8 (assuming standard bitflags)
shift = Bitwise.&&&(mod_code, 1) != 0
ctrl = Bitwise.&&&(mod_code, 2) != 0
alt = Bitwise.&&&(mod_code, 4) != 0
meta = Bitwise.&&&(mod_code, 8) != 0
# Base data map
data = %{
shift: shift,
ctrl: ctrl,
alt: alt,
meta: meta,
char: nil,
key: nil
}
# Character or Special Key
translate_key_or_char(data, char_code, key_code)
end
defp translate_key_or_char(data, char_code, _key_code) when char_code > 0 do
Map.put(data, :char, <<char_code::utf8>>)
end
defp translate_key_or_char(data, _char_code, 65), do: Map.put(data, :key, :up)
defp translate_key_or_char(data, _char_code, 66),
do: Map.put(data, :key, :down)
defp translate_key_or_char(data, _char_code, 67),
do: Map.put(data, :key, :right)
defp translate_key_or_char(data, _char_code, 68),
do: Map.put(data, :key, :left)
defp translate_key_or_char(data, _char_code, 265),
do: Map.put(data, :key, :f1)
defp translate_key_or_char(data, _char_code, 266),
do: Map.put(data, :key, :f2)
defp translate_key_or_char(data, _char_code, _key_code),
do: Map.put(data, :key, :unknown)
# Helper for mouse button translation
defp translate_mouse_button(btn_code) do
# Based on test simulation (button: 0 -> left?)
# Actual mapping depends on rrex_termbox v2.0.1 constants/values
case btn_code do
# Based on test expectations: 0=left, 1=right, 2=middle
0 -> :left
1 -> :right
2 -> :middle
3 -> :wheel_up
4 -> :wheel_down
_ -> :unknown
end
end
defp parse_test_input(input_data) when is_binary(input_data) do
Raxol.Core.Runtime.Log.debug(
"[TerminalDriver.parse_test_input] Parsing: #{inspect(input_data)}"
)
case InputParser.parse(input_data) do
[event | _] -> event
[] -> %Event{type: :unknown_test_input, data: %{raw: input_data}}
end
end
defp translate_event_map(event_map) do
# Handle event structure from rrex_termbox v2.0.1 NIF
case event_map do
%{type: :key, key: key_code, char: char_code, mod: mod_code} ->
# Translate key_code, char_code, mod_code to Raxol's key event format
translated_key = translate_key(key_code, char_code, mod_code)
event = %Event{type: :key, data: translated_key}
{:ok, event}
%{type: :resize, width: w, height: h} ->
event = %Event{type: :resize, data: %{width: w, height: h}}
{:ok, event}
%{type: :mouse, x: x, y: y, button: btn_code} ->
# Translate rrex_termbox mouse button codes and potentially event types
translated_button = translate_mouse_button(btn_code)
# Add button info to the data
event = %Event{
type: :mouse,
data: %{x: x, y: y, button: translated_button}
}
{:ok, event}
# Add cases for other event types rrex_termbox might send
_other ->
# Raxol.Core.Runtime.Log.debug("Ignoring unknown termbox event type: #{inspect(event_map)}")
:ignore
end
end
end