Ghostty

Terminal emulator library for the BEAM.

Wraps libghostty-vt — the virtual terminal extracted from Ghostty. SIMD-optimized VT parsing, full Unicode, 24-bit color, scrollback with text reflow. Terminals are GenServers.

Installation

def deps do
[{:ghostty, "~> 0.4"}]
end

Precompiled terminal and PTY NIF binaries are downloaded automatically for x86_64 Linux, aarch64 Linux, and aarch64 macOS.

Usage

{:ok, term} = Ghostty.Terminal.start_link(cols: 120, rows: 40)
Ghostty.Terminal.write(term, "Hello, \e[1;32mworld\e[0m!\r\n")
{:ok, text} = Ghostty.Terminal.snapshot(term)
# => "Hello, world!"
{:ok, html} = Ghostty.Terminal.snapshot(term, :html)
# => HTML with inline color styles
{col, row} = Ghostty.Terminal.cursor(term)
# => {0, 1}

Color themes

Configure default foreground, background, cursor, and ANSI palette colors when starting a terminal. A palette list starts at index 0; a map can override sparse indices while retaining libghostty-vt's built-in values for the rest.

{:ok, term} =
Ghostty.Terminal.start_link(
foreground: {205, 214, 244},
background: {30, 30, 46},
cursor_color: {245, 224, 220},
palette: %{
1 => {243, 139, 168},
4 => {137, 180, 250}
}
)
Ghostty.Terminal.theme(term)
# => %{foreground: ..., background: ..., cursor: ..., palette: [...]}

Effective foreground and background colors are also included in Ghostty.Terminal.render_state/1 so custom renderers can apply the theme.

Supervision

children = [
{Ghostty.Terminal, name: :console, cols: 120, rows: 40},
{Ghostty.Terminal, name: :logs, id: :logs, cols: 200, rows: 100,
max_scrollback: 100_000}
]
Supervisor.start_link(children, strategy: :one_for_one)
Ghostty.Terminal.write(:console, data)
{:ok, html} = Ghostty.Terminal.snapshot(:console, :html)

Messages

Terminal effects

Effect messages are sent to the process that called start_link/1:

MessageTrigger
{:pty_write, binary}Query responses to write back to the PTY
:bellBEL character (\a)
:title_changedTitle change via OSC 2

Subprocess output

Ghostty.PTY sends messages to the process that called start_link/1:

MessageTrigger
{:data, binary}Subprocess stdout/stderr
{:exit, status}Subprocess exit

Resize with reflow

Ghostty.Terminal.write(term, String.duplicate("x", 120) <> "\r\n")
Ghostty.Terminal.resize(term, 40, 24)
{:ok, text} = Ghostty.Terminal.snapshot(term)
# Long line is now wrapped across 3 lines

Strip ANSI

{:ok, term} = Ghostty.Terminal.start_link(cols: 200, rows: 500)
Ghostty.Terminal.write(term, ansi_output)
{:ok, plain} = Ghostty.Terminal.snapshot(term)

Render state

Read the screen as a grid of cells for building custom renderers (LiveView, Scenic, etc.):

rows = Ghostty.Terminal.cells(term)
for row <- rows do
for {grapheme, fg, bg, flags} <- row do
if Ghostty.Terminal.Cell.bold?({grapheme, fg, bg, flags}) do
IO.write(IO.ANSI.bright())
end
IO.write(grapheme)
end
IO.puts("")
end

Current terminal TTY

Use Ghostty.TTY for local terminal applications that need raw keyboard input from the terminal running the BEAM process:

{:ok, tty} = Ghostty.TTY.start_link()
Ghostty.TTY.write(tty, [IO.ANSI.clear(), IO.ANSI.home(), "Ready"])
receive do
{Ghostty.TTY, ^tty, {:key, %Ghostty.KeyEvent{key: :enter}}} -> :submitted
{Ghostty.TTY, ^tty, {:resize, cols, rows}} -> {cols, rows}
end

Ghostty.TTY complements Ghostty.PTY: TTY is the current terminal; PTY is for child pseudo-terminals. Raw terminal bytes are decoded by Ghostty.KeyDecoder. On OTP 28+, backend: :auto uses OTP's documented raw terminal mode. On older OTP releases, use backend: :nif, takeover: true only when the application owns the terminal and may stop OTP's shell reader. See examples/tty_keys.exs for an interactive smoke test.

PTY

Run interactive programs in a real pseudo-terminal:

{:ok, term} = Ghostty.Terminal.start_link(cols: 80, rows: 24)
{:ok, pty} =
Ghostty.PTY.start_link(
cmd: "/bin/bash",
cols: 80,
rows: 24,
reader_start_timeout: 1_000
)
# PTY output arrives as messages
receive do
{:data, data} -> Ghostty.Terminal.write(term, data)
end
# Send keyboard input
Ghostty.PTY.write(pty, "ls --color\r")
# Resize the PTY (reflows in the terminal too)
Ghostty.PTY.resize(pty, 120, 40)
Ghostty.Terminal.resize(term, 120, 40)

LiveView

Install the LiveView hook into a Phoenix app with:

mix igniter.install ghostty

Then drop in a terminal with Ghostty.LiveTerminal.Component — it handles keyboard events internally so your LiveView only manages the terminal and PTY lifecycle:

defmodule MyAppWeb.TerminalLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, term} = Ghostty.Terminal.start_link(cols: 80, rows: 24)
{:ok, assign(socket, term: term, pty: nil)}
end
def render(assigns) do
~H"""
<.live_component
module={Ghostty.LiveTerminal.Component}
id="term"
term={@term}
pty={@pty}
fit={true}
autofocus={true}
/>
"""
end
def handle_info({:terminal_ready, "term", cols, rows}, socket) do
{:ok, pty} = Ghostty.PTY.start_link(cmd: "/bin/bash", cols: cols, rows: rows)
{:noreply, assign(socket, pty: pty)}
end
def handle_info({:data, data}, socket) do
Ghostty.Terminal.write(socket.assigns.term, data)
send_update(Ghostty.LiveTerminal.Component, id: "term", refresh: true)
{:noreply, socket}
end
def handle_info({:exit, _status}, socket), do: {:noreply, socket}
end

Component assigns

AssignDefaultDescription
:termrequiredGhostty.Terminal pid
:ptynilGhostty.PTY pid; key input is written here when present
:fitfalseAuto-fit terminal size to the rendered container
:autofocusfalseFocus the hidden terminal input on mount
:class""CSS class for the container div

When fit is enabled, the hook measures the container and sends a "ready" event with the computed cols and rows. The component resizes the terminal and notifies the parent with {:terminal_ready, id, cols, rows} — use this to defer PTY startup until the real container size is known.

Low-level helpers

For full control, use the helpers directly:

Ghostty.LiveTerminal.key_event_from_params(params) # parse browser key event
Ghostty.LiveTerminal.handle_key(term, params) # parse + encode
Ghostty.LiveTerminal.push_render(socket, "term-id", term) # push cells to client

Asset bundling

mix igniter.install ghostty vendors ghostty.js into your app assets and wires GhosttyTerminal into assets/js/app.js automatically.

TypeScript source lives in priv/ts/ and is bundled at compile time via OXC — no Node.js or Bun required for end users.

Contributors can run TypeScript quality checks:

bun install && bun run lint && bun run format:check

Demo app

See examples/live_terminal/ for a complete runnable app with Playwright browser tests. It includes a control panel with preset commands, fit/banner toggles, and sets TERM=xterm-256color for colorized shell output.

ExUnit helpers

Ghostty.Test provides concise test helpers without expanding the core Ghostty.Terminal API:

defmodule MyTerminalTest do
use ExUnit.Case, async: true
import Ghostty.Test
test "renders output" do
{:ok, terminal} = term(cols: 80, rows: 24)
terminal
|> lines(["Hello", IO.ANSI.red(), "red", IO.ANSI.reset()])
|> assert_text("Hello")
|> refute_text("missing")
|> assert_snap("test/fixtures/terminal/basic.txt")
end
end

Set UPDATE_GHOSTTY_SNAPSHOTS=1 to rewrite snapshot fixture files.

Examples

See the examples/ directory:

ExampleWhat it does
hello.exsWrite with colors, read back plain + HTML
ansi_stripper.exsPipe stdin, strip ANSI codes
html_recorder.exsCapture command output as styled HTML
progress_bar.exs\r overwrites → final screen state only
reflow.exsText reflow on resize
supervised.exsNamed terminals in a supervision tree
diff.exsTerminal-aware Myers diff
expect.exsExpect-like automation with pattern matching
pool.exsReusable terminal pool for concurrent processing
live_terminal/Phoenix LiveView terminal renderer with Playwright browser tests

Development

Zig 0.15+ is only required for source builds.

git clone https://github.com/dannote/ghostty_ex
cd ghostty_ex
mix deps.get
mix ghostty.setup # clones Ghostty, builds libghostty-vt
GHOSTTY_BUILD=1 mix test

To use an existing Ghostty checkout:

GHOSTTY_SOURCE_DIR=~/code/ghostty mix ghostty.setup

Troubleshooting

Xcode 26.4 breaks Zig builds on macOS. Downgrade to Xcode 26.3 CLT:

# Download from https://developer.apple.com/download/all/?q=Command+Line+Tools+for+Xcode+26.3
sudo xcode-select --switch /Library/Developer/CommandLineTools

See ziglang/zig#31658 for details.

License

MIT