Yeesh
A LiveView terminal component with sandboxed command execution.
Yeesh provides a browser-based CLI with fish/zsh-like features (tab completion, command history, prompt customization) and Dune-powered sandboxed Elixir evaluation.
Features
- xterm.js-powered terminal -- full terminal emulation in the browser with GPU-accelerated rendering, ANSI colors, scrollback, selection, and web links
- Command behaviour -- define custom commands with a simple behaviour
- Tab completion -- command name completion out of the box, including multi-word command names
- Command history -- up/down arrow navigation through previous commands
- Sandboxed Elixir REPL -- evaluate Elixir code safely via Dune, with configurable allowlists, memory/reduction limits, and atom leak prevention
- ANSI output helpers --
Yeesh.Outputprovides colored/styled output - Per-session state -- each terminal instance gets isolated history, environment variables, and Dune session state
Installation
Add yeesh to your dependencies in mix.exs:
def deps do
[
{:yeesh, "~> 0.1.0"}
]
endInstall the library, but do not compile it yet:
mix deps.getInstall the JavaScript dependencies into the library, then compile the library:
npm install --prefix deps/yeesh/assets
mix deps.compile yeesh
Import the Yeesh terminal web component into your app.js:
import "phoenix-colocated/yeesh"
Insert the import line high above in the app.js, ideally immediately after the
import {LiveSocket} from "phoenix_live_view" line.
Under the <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
line in your root.html.heex add the following line:
<Yeesh.Live.TerminalComponent.xterm_style/>Quick Start
Add the terminal component to any LiveView:
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
commands={[]}
prompt="app> "
/>
By default, only the help built-in command is registered.
Built-in Commands
Yeesh ships with several built-in commands: help, clear, history, echo,
env, and elixir (sandboxed REPL). The :builtins assign controls which of
these are available:
| Value | Effect |
|---|---|
:help (default) |
Only the help command |
:all | All built-in commands |
:none | No built-in commands at all |
| list of modules | Exactly those modules |
<%!-- All built-ins --%>
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
builtins={:all}
/>
<%!-- Only help + history --%>
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
builtins={[Yeesh.Builtin.Help, Yeesh.Builtin.History]}
/>
<%!-- No built-ins at all --%>
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
builtins={:none}
commands={[MyApp.Commands.Status]}
/>Custom Commands
Implement the Yeesh.Command behaviour:
defmodule MyApp.Commands.Deploy do
@behaviour Yeesh.Command
@impl true
def name, do: "deploy"
@impl true
def description, do: "Deploy the application"
@impl true
def usage, do: "deploy [environment]"
@impl true
def execute([], session), do: {:error, "specify an environment", session}
def execute([env], session) do
# Your deployment logic here
{:ok, "Deployed to #{env}", session}
end
endRegister it in the component:
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
builtins={:all}
commands={[MyApp.Commands.Deploy]}
/>Multi-word command names
A command name may contain whitespace, in which case the command is invoked
by typing all of its words in order. Any run of whitespace -- whether in the
name returned by name/0 or in the user's input -- is treated as a single
separator, and leading/trailing whitespace is ignored:
defmodule MyApp.Commands.MixRun do
@behaviour Yeesh.Command
@impl true
def name, do: "mix run"
@impl true
def description, do: "Run a Mix task"
@impl true
def usage, do: "mix run <task> [args...]"
@impl true
def execute([task | args], session) do
{:ok, "running #{task} with #{inspect(args)}", session}
end
end$ mix run my_task arg1 arg2
running my_task with ["arg1", "arg2"]
When dispatching, the registry is consulted first and the longest
registered multi-word name that matches a prefix of the input wins. So if
both mix and mix run are registered, mix run foo dispatches to
mix run with ["foo"], while mix foo dispatches to mix with
["foo"]. Quoting still works the usual way for individual arguments,
e.g. mix run "hello world".
Command Grouping
The help command groups output automatically based on command names.
Command names may contain dots (.), dashes (-), and underscores (_) as
separators. The text before the first separator determines the group:
- Built-in commands are always grouped under "Built-in".
- Commands that implement
group/0use the returned string as the group name (takes precedence over automatic grouping). - Commands without a separator (e.g.
deploy) appear under "Generic". - Commands with a separator are grouped by their prefix, capitalized.
For example,
db.migrate,db-seed, anddb_statusall appear under "Db".
Groups are displayed in order: Built-in first, Generic second, then custom groups alphabetically.
Explicit groups
Implement the optional group/0 callback to override automatic grouping:
defmodule MyApp.Commands.Migrate do
@behaviour Yeesh.Command
@impl true
def name, do: "db.migrate"
@impl true
def group, do: "Database"
@impl true
def description, do: "Run database migrations"
@impl true
def usage, do: "db.migrate [--step N]"
@impl true
def execute(_args, session), do: {:ok, "Migrated", session}
end
Without group/0, this command would appear under "Db" (derived from the
name prefix). With it, it appears under "Database" instead.
Example output
Built-in:
help Show available commands or help for a specific command
clear Clear the terminal screen
Generic:
deploy Deploy the application
Database:
db.migrate Run database migrations
db.seed Seed the database
Sys:
sys.info Show system information
sys.health Run health checksElixir REPL
The built-in elixir command provides a sandboxed Elixir evaluation
environment powered by Dune:
$ elixir 1 + 2
3
$ elixir
Entering sandboxed Elixir REPL (powered by Dune).
Type 'exit' to return to the shell.
iex> x = 42
42
iex> x * 2
84
iex> exit
$Variables persist within the session. Dangerous functions (file system, network, code loading) are restricted by Dune's allowlist.
Configure the sandbox:
<.live_component
module={Yeesh.Live.TerminalComponent}
id="terminal"
sandbox_opts={[timeout: 10_000, max_reductions: 100_000]}
/>Configuration
:prompt-- prompt string (default:"$ "):commands-- list of command modules (default:[]):builtins-- which built-in commands to register::all,:none,:help, or a list of builtin modules (default::help):theme-- terminal theme,:defaultor:light(default::default):context-- arbitrary map passed to commands (default:%{}):sandbox_opts-- Dune sandbox configuration (default:[])
Execution Model
Command execution is currently synchronous -- the LiveView process blocks until the command completes (with a configurable timeout, default 5s).
Async streaming execution is planned for Milestone 3.
Roadmap
- Milestone 2: Argument-level tab completion, fish-style auto-suggestions, syntax highlighting, Ctrl+R history search, aliases, theming, OS command passthrough (explicit opt-in with allowlist)
- Milestone 3: Async streaming execution for long-running commands, pipe support, output paging, session persistence
License
MIT