<img src="https://raw.githubusercontent.com/Oeditus/yeesh/v0.1.0/stuff/img/logo-128x128.jpg" alt="Yeesh" width="128" align="right">
# 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.Output` provides 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`:
```elixir
def deps do
[
{:yeesh, "~> 0.1.0"}
]
end
```
Install the library, but do not compile it yet:
```
mix deps.get
```
Install the JavaScript dependencies into the library, then compile the library:
```bash
npm install --prefix deps/yeesh/assets
mix deps.compile yeesh
```
Import the Yeesh terminal web component into your `app.js`:
```javascript
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:
```CSS
<Yeesh.Live.TerminalComponent.xterm_style/>
```
## Quick Start
Add the terminal component to any LiveView:
```elixir
<.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 |
```elixir
<%!-- 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:
```elixir
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
end
```
Register it in the component:
```elixir
<.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:
```elixir
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/0`** use 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`, and `db_status` all 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:
```elixir
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 checks
```
## Elixir REPL
The built-in `elixir` command provides a sandboxed Elixir evaluation
environment powered by [Dune](https://hexdocs.pm/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:
```elixir
<.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, `:default` or `: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