Yeesh

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:

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:

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:

ValueEffect
:help (default)Only the help command
:allAll built-in commands
:noneNo built-in commands at all
list of modulesExactly 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
end

Register 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/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:

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:

$ 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, :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