Getting Started
Warning
orzma is in early development and may introduce breaking changes.
orzma is a terminal emulator that can render web pages inside the terminal. A program running in an orzma pane can place a live web page among its own text and exchange messages with it, so a terminal app can show rendered Markdown, diagrams, or a website without leaving the terminal. orzma also splits its window into panes, so you do not need a separate terminal multiplexer.
Supported platforms
- macOS 11 or later on Apple Silicon.
- Windows 10 version 1809 or later, and Windows 11 (x64).
- Linux (x86_64) with glibc 2.35 or later. The
.debis tested on Ubuntu 22.04 and 24.04.
Install
macOS
Install orzma with Homebrew:
brew install --cask not-elm/orzma/orzma
This adds the not-elm/homebrew-orzma tap and installs orzma.app into
/Applications. To upgrade later, run:
brew upgrade --cask orzma
Windows
Download orzma-<version>-x64.msi from the
latest release and run it.
The installer is per-user: it needs no administrator prompt, installs into
%LocalAppData%\Programs\orzma, and puts orzma, orzmd, and orzbrowser on
your PATH.
Linux
Use either the .deb or the tarball, not both: with both installed,
~/.local/bin/orzma usually shadows /usr/bin/orzma.
On Ubuntu, Debian, and other Debian-based distributions, download
orzma_<version>_amd64.deb from the
latest release and install
it with apt, which also installs the system libraries orzma needs:
sudo apt install ./orzma_<version>_amd64.deb
The package puts orzma, orzmd, and orzbrowser in /usr/bin. To uninstall,
run sudo apt remove orzma; your settings in ~/.config/orzma are kept.
On other distributions, first install the system libraries that orzma and its
embedded Chromium need. The package names below are Ubuntu’s and Debian’s, so
install your distribution’s equivalents; on Ubuntu 24.04 or later and Debian 13
or later, libasound2 is named libasound2t64.
sudo apt install libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libgbm1 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libxfixes3 \
libpango-1.0-0 libcairo2 libgtk-3-0 libasound2 libdbus-1-3 libglib2.0-0 \
libudev1 libwayland-client0 libfontconfig1 \
libx11-6 libx11-xcb1 libxcursor1 libxi6 libxkbcommon-x11-0 libvulkan1 libegl1
Then download orzma-<version>-x86_64-linux.tar.gz from the
latest release and run its
installer:
tar xzf orzma-<version>-x86_64-linux.tar.gz
cd orzma-<version>-x86_64-linux
./install.sh
The installer is per-user and needs no root. It copies orzma into
~/.local/share/orzma ($XDG_DATA_HOME/orzma when XDG_DATA_HOME is set),
links ~/.local/bin/orzma, and adds orzma to your desktop’s application list.
It does not put orzmd and orzbrowser on your PATH; add
~/.local/share/orzma to your PATH to run them by name. You can also run ./orzma straight from the
extracted directory without installing. To uninstall, run
~/.local/share/orzma/uninstall.sh; your settings in ~/.config/orzma are
kept.
Every install includes the companion apps orzmd and
orzbrowser. To build orzma from source, see Contributing.
Multiplexer
orzma can split its window into panes, each running its own shell. The panes are part of orzma itself, so there is no separate multiplexer to start.
| Default keys | Action |
|---|---|
Leader, then i | Split the active pane side by side. |
Leader, then o | Split the active pane top and bottom. |
Leader, then h / j / k / l | Make the pane on the left / below / above / on the right active. |
Leader, then p | Close the active pane and end its shell. |
Leader, then Shift+H / Shift+J / Shift+K / Shift+L | Move a divider of the active pane 5 cells left / down / up / right. |
The leader is a tap of Cmd on macOS or Alt on Windows and Linux: press and
release it on its own, then press the next key. See
Key Bindings to change the leader or these keys.
A new pane becomes the active pane. You can also click a pane to make it active, and drag the border between two panes to resize them. When the last pane closes, orzma quits.
Resizing panes
The resize keys move one divider of the active pane 5 cells in the key’s
direction, picking it the way tmux’s resize-pane does. They are repeatable:
press the key again within repeat-time-ms (500 ms by default), without the
leader, to keep moving the divider (see
Repeatable bindings).
Left and right look at the row of side-by-side panes the active pane belongs to (up and down at its column of stacked panes): the divider after the pane moves when there is one, and otherwise the divider before it. So left and right move the active pane’s right border unless the pane is the last one in its row. Panes nested inside the area that grows or shrinks keep their proportions; when the active pane is one of them, it changes by only its share of the 5 cells (possibly none) and its other border can move as well.
A divider stops once the side it shrinks reaches 4 columns or 2 rows per pane (less when the area the divider splits is too small to give both sides that much), so a small pane nested on that side can still end up narrower. A divider never moves against the key, and a key with no divider to move on its axis does nothing. In vi mode each step needs the leader again.
Working directory of a new pane
A pane made by a split starts in the working directory of the pane it was split from:
- On macOS and Linux, orzma asks the system for the directory of the pane’s foreground program. If the system does not report one, orzma uses the directory the shell last reported with the OSC 7 or OSC 9;9 escape sequence, and then the directory the pane started in.
- On Windows, the directory the shell last reported comes first. orzma makes
PowerShell (
pwshandpowershell) andcmdreport it automatically; setshell_integration = falsein the[orzma]table to turn this off.
orzma uses the directory only if it still exists; otherwise, and for the first pane, the new pane starts in your home directory.
Inactive panes
Panes that do not have focus are drawn with a tint. Change or turn off the
effect in the [inactive_pane] table.
Vi Mode
Vi mode moves a cursor over the pane’s screen and scrollback with vi keys, so
you can select and copy text without the mouse. Press <Leader>s to enter it.
Press y or Enter to copy the selection and leave, or q or Escape to
leave without copying.
Note
The search and jump actions (
/,?,n,N,f,F,t,T) are not implemented yet: vi mode swallows those keys and does nothing.toggle-rect-selection(Ctrl+V) currently selects whole lines, because rectangular selection is not implemented yet.
The mouse keeps working in vi mode, even while a program such as nvim tracks the mouse: a click moves the vi cursor, and a drag selects text (and moves the vi cursor to where the drag ends). A selection started with the mouse can be extended with the motion keys, while a click or a drag always starts a new selection. On the primary screen, where the shell runs, the wheel scrolls the scrollback. On the alternate screen, where full-screen programs such as nvim and less run, the wheel sends arrow keys to the program.
Keys
Vi-mode keys are set in the [vi-mode] table of
the configuration file. A [vi-mode] entry is an optional Ctrl+ prefix plus
exactly one key.
- Keys are either a single character, matched case-sensitively
(
"w"and"W"are different bindings — Shift is expressed through the character’s case, e.g."W"means Shift+w, not"Shift+w"), or one of the named keysEscapeEnterSpaceTabBackspaceArrowUpArrowDownArrowLeftArrowRight. Ctrl+is the only modifier prefix accepted.Cmd+,Alt+,Shift+(and their aliases) are parse errors inside[vi-mode]— Shift is expressed via character case as above, and Cmd/Alt chords are reserved for application shortcuts ([shortcuts]); vi mode does not match keystrokes with Cmd or Alt held.- After
Ctrl+, the key must be an ASCII alphanumeric character or a named key —Ctrl+$is a parse error.Ctrl+entries match on the physical key pressed (not the character it produces), so they behave the same regardless of layout or case, and they do not match while Shift is also held. - Values are a single key string (
yank = "Y") or an array of key strings (exit = ["q", "Escape", "Ctrl+C"]); any action can be bound to zero, one, or several keys. ""or[]unbinds an action (for example,search-forward = "").- Duplicate keys are a startup error: if the same key, however it is
spelled (
"Space"and" ","Ctrl+F"and"ctrl+f"), is bound more than once in[vi-mode], orzma fails at startup naming every colliding action (as duplicate chords are in[shortcuts]). An unknown action name, like the parse errors above, makes orzma ignore the whole file instead (see Validation).
Shadowing note: [shortcuts] chords (both leader-scoped and direct) are
matched before [vi-mode] keys. If the same keystroke is bound in both
tables, the [shortcuts] action normally fires and the [vi-mode] binding
never sees it — e.g. setting leader = "Ctrl+B" shadows the default
page-up = "Ctrl+B" binding while vi mode is active. orzma does not
validate across the two tables; check your own bindings for overlap.
Two actions decline the keystroke instead of shadowing it, so the [vi-mode]
binding still runs:
- A direct
pastechord does nothing in vi mode, so it passes through — the stockCtrl+Von Windows and Linux reachestoggle-rect-selection. A<Leader>-scopedpastebinding still pastes. copypasses a Ctrl-only chord through whenever there is no selection to copy — the stockCtrl+Con Windows and Linux reachesexit. With a selection, it copies and clears the selection without leaving vi mode.
Actions
| Action | Default | What it does |
|---|---|---|
cursor-left | h, ArrowLeft | Move the cursor one cell left. |
cursor-down | j, ArrowDown | Move the cursor one cell down. |
cursor-up | k, ArrowUp | Move the cursor one cell up. |
cursor-right | l, ArrowRight | Move the cursor one cell right. |
line-start | 0 | Jump to column 0 (of the first row, on a wrapped line). |
line-end | $ | Jump to the last non-blank column; pressed again, jump to the last column, or to the end of the text on a wrapped line. |
line-first-char | ^ | Jump to the first non-blank column. |
next-word | w | Jump to the next (semantic) word start. |
previous-word | b | Jump to the previous (semantic) word start. |
next-word-end | e | Jump to the next (semantic) word end. |
next-space | W | Jump to the next space-delimited word start. |
previous-space | B | Jump to the previous space-delimited word start. |
next-space-end | E | Jump to the next space-delimited word end. |
screen-top | H | Jump to the top visible line. |
screen-middle | M | Jump to the middle visible line. |
screen-bottom | L | Jump to the bottom visible line. |
previous-paragraph | { | Jump to the previous paragraph boundary. |
next-paragraph | } | Jump to the next paragraph boundary. |
matching-bracket | % | Jump to the bracket matching the one under the cursor. |
history-top | g | Scroll to the oldest history line. |
history-bottom | G | Scroll to the live tail. |
page-up | Ctrl+B | Scroll one page up. |
page-down | Ctrl+F | Scroll one page down. |
half-page-up | Ctrl+U | Scroll half a page up. |
half-page-down | Ctrl+D | Scroll half a page down. |
scroll-up | Ctrl+Y | Scroll one line up. |
scroll-down | Ctrl+E | Scroll one line down. |
toggle-selection | v, Space | Toggle a character-wise selection. |
toggle-line-selection | V | Toggle a line-wise selection. |
toggle-rect-selection | Ctrl+V | Toggle a rectangular selection. Currently selects whole lines. |
yank | y, Enter | Copy the selection to the clipboard and leave vi mode. |
exit | q, Escape, Ctrl+C | Leave vi mode. |
search-forward | / | Open the search-down prompt (not implemented yet). |
search-backward | ? | Open the search-up prompt (not implemented yet). |
search-next | n | Repeat the previous search (not implemented yet). |
search-previous | N | Repeat the previous search, reversed (not implemented yet). |
jump-forward | f | Open the jump-to-char-forward prompt (not implemented yet). |
jump-backward | F | Open the jump-to-char-backward prompt (not implemented yet). |
jump-to-forward | t | Open the jump-till-char-forward prompt (not implemented yet). |
jump-to-backward | T | Open the jump-till-char-backward prompt (not implemented yet). |
The semantic word motions (w, b, e) stop at whitespace and at the
characters in [selection] semantic_escape_chars.
Escape and unbound keys
By default, Escape is bound to the exit action, which leaves vi mode
entirely. To deselect without leaving vi mode, press the toggle key that
matches the selection’s kind: v (or Space) clears a character-wise
selection and V a line-wise one. The other key switches the selection to its
own kind instead of clearing it.
Keys not bound to any [vi-mode] action are swallowed while vi mode is
active (they never reach the pane) — this includes stock copy-mode-vi keys
that orzma does not carry over by default, such as : (goto-line), digit
repeat prefixes, o (other-end), A (append-and-cancel), X / M-x
(mark), ; / , (jump repeat), z (scroll-middle), and D
(copy-end-of-line-and-cancel). orzma has no actions for these; you can only
bind such a key to one of the actions above.
Companion Apps
orzma comes with two terminal apps that show web pages inside the terminal,
built with the ratatui_orzma SDK: orzmd, a Markdown viewer, and
orzbrowser, a keyboard-driven browser. Homebrew links both into your PATH, and the
Windows installer and the Linux .deb add them to it; the Linux tarball keeps
them in ~/.local/share/orzma (see Getting Started).
To build them from a clone of the repository instead, run just install-apps.
Both run only inside an orzma pane. Anywhere else, they exit with an error such as:
orzmd: not inside an orzma pane: ORZMA_SOCK is unset. Run orzmd inside an orzma pane.
orzmd
orzmd runs inside an orzma pane and renders Markdown in an embedded webview, wrapped in native terminal chrome: a status line, an optional outline panel, and a search line. You drive it from the keyboard like a pager, while the page handles rich rendering — diagrams, math, and highlighted code.
Features
- Live reload — saving the file re-renders it automatically, and your scroll position is preserved across reloads.
- Syntax highlighting — fenced code blocks are highlighted with highlight.js.
- Math — LaTeX rendered with KaTeX (
$inline$and$$block$$). - Mermaid diagrams —
```mermaidfences render as diagrams. - GitHub alerts —
> [!NOTE],[!TIP],[!IMPORTANT],[!WARNING], and[!CAUTION]blockquotes render as colored callout boxes with icons. - Outline panel — jump between the document’s headings.
- In-page search — highlight matches and step through them.
Usage
orzmd <markdown-file>
The status line at the top shows the file name, the live-reload state, and the scroll position:
orzmd · README.md ● live 42%
● live means the file is being watched. If the file is deleted, the status
switches to ○ missing and the last rendered content stays on screen.
On Windows, local images referenced by a document are staged as symlinks when Windows allows it (Developer Mode is on, or orzmd runs as administrator) and copied otherwise; a copied image does not refresh until orzmd is restarted.
Keyboard shortcuts
Reading
| Key | Action |
|---|---|
j / ↓ | Scroll down one line |
k / ↑ | Scroll up one line |
Ctrl-d / Ctrl-u | Scroll half a page down / up |
Ctrl-f / Ctrl-b | Scroll a full page down / up |
Space / PageDown | Scroll a full page down |
PageUp | Scroll a full page up |
gg | Jump to the top |
G | Jump to the bottom |
]] / [[ | Jump to the next / previous heading |
o / Tab | Toggle the outline panel |
/ | Start a search |
n / N | Next / previous match (after a search) |
r | Reload the file |
q / Ctrl-c | Quit |
While the page has keyboard focus, Ctrl-c copies in the page instead of
quitting; q still quits, because orzmd forwards it to the TUI even while
the page is focused.
Outline panel
| Key | Action |
|---|---|
j / ↓ | Move the selection down |
k / ↑ | Move the selection up |
Enter | Jump to the selected heading |
o / Tab / Esc | Close the panel |
q | Quit |
Search
| Key | Action |
|---|---|
| (type) | Build the query |
Backspace | Delete the last character |
Enter | Run the search |
Esc | Cancel |
After running a search, use n / N in reading mode to move between matches,
and Esc to clear the highlight.
orzbrowser
orzbrowser runs inside an orzma pane and loads a remote URL in an embedded webview, wrapped in native terminal chrome: a status line, an address bar, and a help modal. You drive it from the keyboard like a Vim-style pager — scrolling, following links by typing hint labels, and stepping through history — while the page renders in the webview.
Features
- Vim-style scrolling —
j/kmove by line,Ctrl-d/Ctrl-uby half a page,Ctrl-f/Ctrl-bby a full page, andgg/Gjump to the top / bottom. - Link hints — press
fto overlay labels on every link and form field, then type a label to follow it. Landing on a text field switches to Insert mode automatically. - History —
HandLstep back and forward through the session history. - Address bar —
o(or:) opens the address bar pre-filled with the current URL; a scheme-less entry likegithub.comis completed tohttps://. - Modal input — Normal, Insert, Address, Hint, and Help modes.
ihands keyboard focus to the page so you can type into it;Escreturns to Normal. - In-app help —
?shows the full shortcut list.
Usage
orzbrowser <url>
The status line at the top shows the current mode and the loaded URL:
[Normal] https://example.com
Opening the address bar with o or : replaces it with an editable prompt:
> https://example.com_
Keyboard shortcuts
Normal
| Key | Action |
|---|---|
j / ↓ | Scroll down one line |
k / ↑ | Scroll up one line |
Ctrl-d / Space | Scroll half a page down |
Ctrl-u | Scroll half a page up |
Ctrl-f / PageDown | Scroll a full page down |
Ctrl-b / PageUp | Scroll a full page up |
gg | Jump to the top |
G | Jump to the bottom |
H | History back |
L | History forward |
o / : | Open the address bar |
r | Reload the page |
i | Insert mode (focus the webview) |
f | Follow a link (show hints) |
? | Show help |
q / Ctrl-c | Quit |
While the page has keyboard focus, Ctrl-c copies in the page instead of
quitting; q still quits, since Normal mode forwards it to the TUI even
while the page is focused.
Address bar
| Key | Action |
|---|---|
| (type) | Edit the URL |
Backspace | Delete the last character |
Enter | Navigate to the URL |
Esc | Cancel |
Ctrl-c | Quit |
Hint
| Key | Action |
|---|---|
| (type label) | Narrow to and follow the matching hint |
Backspace | Delete the last label character |
Esc | Cancel hints |
Ctrl-c | Quit |
Insert
| Key | Action |
|---|---|
Esc | Return to Normal mode |
In Insert mode every other key goes to the page, so you can type into focused inputs.
Help
| Key | Action |
|---|---|
Esc / q | Close help |
Ctrl-c | Quit |
Acknowledgements
orzbrowser’s keyboard model and link-hint workflow are inspired by
Vimium, the keyboard-driven browser
extension. The hint alphabet (sadfjklewcmpgh) is Vimium’s default. Vimium is
distributed under the
MIT License;
orzbrowser ships an independent implementation rather than Vimium source.
Configuration
orzma reads its configuration from a TOML file at startup. If the file does not exist, every setting keeps its default.
File location
orzma resolves the config path in this order:
$ORZMA_CONFIG— used verbatim if set.$XDG_CONFIG_HOME/orzma/config.toml— if$XDG_CONFIG_HOMEis set.~/.config/orzma/config.toml— the default.
~ is your home directory on every platform, so on Windows the default is
%USERPROFILE%\.config\orzma\config.toml.
Validation
orzma checks the file when it starts. A problem has one of two effects:
- orzma ignores the whole file and starts with every setting at its
default when the file cannot be read as a configuration: a TOML syntax
error, an unknown section, an unknown key in
[cursor],[orzma],[keyboard],[shortcuts],[vi-mode],[selection], or[font], a value of the wrong type or an unknown word, or a malformed key binding. - orzma does not start when the settings conflict or cannot be applied: a key bound to more than one action, a leader that shadows another binding or cannot be used, a font size or style outside the allowed values, or a font family that is not installed.
In both cases orzma writes the reason to standard error. To read it, start
orzma from a terminal: on macOS, run /Applications/orzma.app/Contents/MacOS/orzma;
on Linux, run orzma; on Windows, run orzma 2> orzma-error.txt and open the
file.
Unknown keys in [mouse] and [inactive_pane] are silently ignored, and the
few values that are silently clamped or reverted are noted in the comments
below.
Settings
Every key below shows its default value. Keep only the lines you want to
change; omitted keys fall back to these defaults. The [shortcuts] table is
described in Key Bindings, and the keys and actions of the
[vi-mode] table in Vi Mode.
[orzma]
[orzma]
# Shell launched in new panes. Default: $SHELL when it is set (on Windows,
# only when that program exists); otherwise /bin/sh on macOS and Linux, and
# on Windows pwsh or powershell if installed, then %COMSPEC% when it is set,
# then cmd.exe. No ~ expansion.
# shell = "/bin/zsh"
# Whether orzma injects a prompt hook into a recognized shell (pwsh,
# powershell, cmd) so a split pane inherits its working directory. Has
# no effect outside Windows.
shell_integration = true
[cursor]
[cursor]
# Unlike the other enum-valued keys, an unrecognized `style` word silently
# reverts to the default instead of making orzma ignore the whole file.
style = "block" # block | underline | bar
blink_interval = 750 # milliseconds; 0 keeps the caret steady whatever a program asks for. Any other value below 10 is silently raised to 10.
blink_timeout = 5 # seconds; 0 blinks indefinitely. Silently raised to twice blink_interval (one full on/off cycle) when shorter.
thickness = 0.15 # f32 0..=1, fraction of the cell width. Out-of-range silently clamps; NaN reverts to 0.15; 0 still draws 1 physical px.
unfocused_hollow = true
# The caret starts blinking. DECSCUSR and DECSET 12 / DECRST 12 both
# change it from there, and a DECSCUSR 0 or 7 restores the blink.
[selection]
[selection]
# Characters that end a word for the vi-mode `w` / `b` / `e` motions,
# besides whitespace. Read at startup. Omit the key to keep this default.
semantic_escape_chars = ",│`|:\"' ()[]{}<>\t"
[font]
[font]
size = 11.25 # f32, logical px. Must be 0 < size <= 200, else startup error.
# Each face is a table of { family, style }. Omit [font] entirely to use the
# bundled JetBrains Mono Nerd Font. A face's `family`, when omitted, inherits
# `normal.family`; its `style`, when omitted, uses the face's default
# (Regular / Bold / Italic / Bold Italic).
#
# `family` is resolved against installed system fonts; a configured family that
# is not installed is a STARTUP ERROR (no silent fallback). `style` selects a
# weight + slant: standard names (Regular/Bold/Italic/Bold Italic) plus common
# weights (Thin, Light, Medium, SemiBold, ExtraBold, Black, ...) optionally with
# Italic/Oblique. An unknown style token is a config error. (Unlike Alacritty,
# `style` is matched by weight+slant attributes, not by exact subfamily name.)
#
# [font.normal]
# family = "JetBrains Mono"
# style = "Regular"
# [font.bold]
# style = "Bold" # inherits family = "JetBrains Mono"
# [font.italic]
# family = "Cascadia Code"
# style = "Italic"
# [font.bold_italic]
# style = "Bold Italic"
#
# ui = { family = "Inter", style = "Medium" }
# The UI-chrome face (window bar, prompts, indicators). `family` and `style`
# each inherit from `normal` when omitted (ui.family -> normal.family,
# ui.style -> normal.style). A configured `ui.family` that is not installed is
# a startup error, same as the terminal faces. `style` uses the same weight +
# slant syntax and is applied to UI text. When no family resolves anywhere
# (bundled fallback), style rounds to the nearest of the four bundled faces.
[keyboard]
[keyboard]
# macOS only. Which Option key sends Meta instead of composing.
option_as_alt = "none" # "none" | "left" | "right" | "both"
[mouse]
[mouse]
lines_per_notch = 3 # u32. Lines scrolled per wheel notch.
fine_modifier = "alt" # "alt" | "ctrl" | "shift" | "none". Modifier for fine (slow) scroll; "shift" has no effect on macOS.
fine_lines = 1 # u32. Lines per notch while fine_modifier is held.
max_protocol_events_per_frame = 8 # u32. Most wheel notches one routing call turns into mouse reports or alternate-scroll cursor keys, per axis; excess notches are dropped, and cursor keys additionally stop at 240 per call.
cells_per_notch = 0.5 # f32. Wheel accumulation threshold per notch, on both axes.
axis_lock_ratio = 0.9 # f32, clamped to 0.0..=1.0; a non-finite value reverts to 0.9. Trackpad dominant-axis lock: horizontal scroll kept only when |x|/hypot(x,y) >= this. 0.0 disables; 1.0 = pure-horizontal only.
double_click_timeout_ms = 400 # u32. Max ms between clicks to count as double/triple.
click_drift_px = 8.0 # f32. Max pointer drift (logical px) between clicks of a multi-click.
divider_grab_tolerance_px = 4.0 # f32. Half-width (logical px) of the pane-divider grab zone; never below half a cell. A non-finite value reverts to 4.0.
# Other mouse numbers are not range-checked; out-of-range values are used as-is.
[inactive_pane]
[inactive_pane]
# Visual treatment of panes that don't have focus. Float fields are clamped
# to 0.0..=1.0 (out-of-range values are silently clamped, not errors).
enabled = true # bool. Set false to disable all inactive-pane treatment.
dim = 1.0 # f32 0..=1. Brightness multiplier (1.0 = no dimming).
tint_color = "#3a3b45" # "#RRGGBB". Background tint target. Invalid hex silently reverts to this default.
tint = 0.85 # f32 0..=1. Tint strength (0 = off, 1 = full tint).
webview_dim = 0.55 # f32 0..=1. Brightness multiplier for inactive webview overlays.
webview_desaturate = 0.6 # f32 0..=1. Desaturation for inactive webviews (0 = full color, 1 = grey).
[vi-mode]
[vi-mode]
# Vi-mode key bindings. The Vi Mode page describes the key syntax, the
# duplicate-key rule, and what each action does.
# --- cursor motion ---
cursor-left = ["h", "ArrowLeft"]
cursor-down = ["j", "ArrowDown"]
cursor-up = ["k", "ArrowUp"]
cursor-right = ["l", "ArrowRight"]
line-start = ["0"]
line-end = ["$"]
line-first-char = ["^"]
next-word = ["w"]
previous-word = ["b"]
next-word-end = ["e"]
next-space = ["W"]
previous-space = ["B"]
next-space-end = ["E"]
screen-top = ["H"]
screen-middle = ["M"]
screen-bottom = ["L"]
previous-paragraph = ["{"]
next-paragraph = ["}"]
matching-bracket = ["%"]
# --- scrolling ---
history-top = ["g"]
history-bottom = ["G"]
page-up = ["Ctrl+B"]
page-down = ["Ctrl+F"]
half-page-up = ["Ctrl+U"]
half-page-down = ["Ctrl+D"]
scroll-up = ["Ctrl+Y"]
scroll-down = ["Ctrl+E"]
# --- selection ---
toggle-selection = ["v", "Space"]
toggle-line-selection = ["V"]
toggle-rect-selection = ["Ctrl+V"]
# --- copy / exit ---
yank = ["y", "Enter"]
exit = ["q", "Escape", "Ctrl+C"]
# --- search / jump (not implemented yet) ---
search-forward = ["/"]
search-backward = ["?"]
search-next = ["n"]
search-previous = ["N"]
jump-forward = ["f"]
jump-backward = ["F"]
jump-to-forward = ["t"]
jump-to-backward = ["T"]
Key Bindings
orzma’s own shortcuts live in the [shortcuts] table of the
configuration file. The keys that work inside vi mode are
a separate table, described on the Vi Mode page.
The leader key
Most actions are bound to the leader followed by one more key, written
<Leader> in the configuration. By default the leader is a tap of a
modifier: Cmd on macOS and Alt on Windows and Linux. Press and release the modifier
with no other key or mouse button in between, then press the action’s key.
Holding the modifier works as usual, so Alt+h still reaches the shell as a
meta-prefixed key.
After a tap, the next keystroke either runs a <Leader> action or is
swallowed if no action matches; the leader does not time out while it waits,
even if you switch to another window and back. Switching windows before you
release the modifier cancels the tap.
leadersets the leader: a modifier to tap ("Cmd","Ctrl", or"Alt";"Shift"is not allowed), a chord such as"Ctrl+A"(press the chord, then the action’s key), or""to turn the leader off.leader-tap-timeout-msis how long a tap may last before it no longer counts, 300 ms by default;0reverts to 300.repeat-time-msis the repeat window of<Leader:r>bindings (below), 500 ms by default;0turns repeating off.
Chord syntax
A chord is zero or more modifiers followed by exactly one key, joined with +.
- Modifiers (case-insensitive):
Cmd(alsoCommand/Meta/Super),Ctrl,Shift,Alt(alsoOpt/Option). - Keys: a letter or a digit (letters are case-insensitive),
[,],-,=, or a named key:EscapeSpaceEnterTabBackspaceArrowUpArrowDownArrowLeftArrowRightPlus. Any other character is accepted but never fires. - For the
+key itself, use the tokenPlus(e.g.Cmd+Plus).
Examples: Cmd+Shift+Q, Ctrl+Alt+ArrowLeft, Cmd+Plus.
Invalid chords — an empty token (Cmd+), an unknown named key (Cmd+F12), a
duplicated modifier (Cmd+Meta+S), or more than one key (Cmd+S+T) — make
orzma ignore the whole file and start with the defaults (see
Validation).
Repeatable bindings (<Leader:r>)
Binding an action with <Leader:r> instead of <Leader> makes it repeatable:
after the binding fires, pressing any repeat-marked key
again within repeat-time-ms (default 500) re-fires its action without
re-pressing the leader, and each fire re-arms the window. Holding the key down
keeps firing (OS key auto-repeat participates). Any other key — including keys
bound with plain <Leader> — closes the window immediately and is handled
normally (it is never swallowed). Pressing the leader inside the window starts
a fresh leader sequence.
Caveat: with a letter key (say <Leader:r>h), typing that same letter into the
shell within the window re-fires the action instead of reaching the terminal.
If that bites, set repeat-time-ms = 0 (disables repeat globally) or drop the
:r marker from that binding.
In vi mode a repeatable binding fires only on the key pressed right after the
leader: the window closes on the next key event, and holding the key does not
keep firing. A second press or an auto-repeat is read as a [vi-mode] key
instead — with the stock bindings, Shift+H and Shift+L jump to the top and
bottom visible line, and Shift+J and Shift+K do nothing.
Platform defaults
Seven defaults differ by platform, because macOS has a Cmd key and the other
platforms do not. Every other action below is the same everywhere.
| Action | Default (macOS) | Default (Windows / Linux) |
|---|---|---|
leader | Cmd (tap) | Alt (tap) |
paste | Cmd+V | Ctrl+V |
copy | Cmd+C | Ctrl+C |
increase-font-size | Cmd+Plus | Ctrl+Plus |
decrease-font-size | Cmd+- | Ctrl+- |
reset-font-size | Cmd+0 | Ctrl+0 |
quit | Cmd+Q | unbound |
quit ships unbound off macOS because the window manager’s own close
shortcut (Alt+F4 on Windows) already exits orzma. Bind it explicitly if you
want a second way out.
Binding paste to Ctrl+V does take that key away from the program running in
the terminal, so readline’s quoted-insert and vim’s visual-block mode no longer
see it. Set paste = "Ctrl+Shift+V" to give it back.
A copy chord that uses Ctrl alone, such as the stock Ctrl+C, copies only
while text is selected; with nothing selected, it reaches the program as usual,
so Ctrl+C still interrupts. A copy chord with any other modifier, such as the
macOS Cmd+C, always copies.
Actions
The Default column lists the macOS value; see “Platform defaults” above for
the seven that differ elsewhere.
| Action | Default | What it does |
|---|---|---|
paste | Cmd+V | Paste from the system clipboard. |
copy | Cmd+C | Copy the focused terminal’s selection to the system clipboard, then dismiss the selection. |
increase-font-size | Cmd+Plus | Step the terminal font size up. |
decrease-font-size | Cmd+- | Step the terminal font size down. |
reset-font-size | Cmd+0 | Return the terminal font size to [font] size. |
release-webview-focus | <Leader>u | Return keyboard focus from a focused webview to the terminal. |
quit | Cmd+Q | Quit orzma. |
enter-vi-mode | <Leader>s | Enter vi mode. |
select-left-pane | <Leader>h | Focus the pane to the left. |
select-down-pane | <Leader>j | Focus the pane below. |
select-up-pane | <Leader>k | Focus the pane above. |
select-right-pane | <Leader>l | Focus the pane to the right. |
resize-left-pane | <Leader:r>Shift+H | Move a divider of the active pane 5 cells left, repeatable (see Resizing panes). |
resize-down-pane | <Leader:r>Shift+J | Move a divider of the active pane 5 cells down, repeatable (see Resizing panes). |
resize-up-pane | <Leader:r>Shift+K | Move a divider of the active pane 5 cells up, repeatable (see Resizing panes). |
resize-right-pane | <Leader:r>Shift+L | Move a divider of the active pane 5 cells right, repeatable (see Resizing panes). |
split-vertical-pane | <Leader>i | Split the active pane side by side (vertical divider); the new pane becomes active. |
split-horizontal-pane | <Leader>o | Split the active pane stacked (horizontal divider); the new pane becomes active. |
kill-pane | <Leader>p | Kill the active pane; its shell is terminated. |
The window actions (new-window, next-window, select-window-0 and the
rest), rename-window, and zoom-pane that orzma 0.1.0 accepted have been
removed. A configuration that still sets one of them is ignored as a whole (see
Validation), so delete those lines when you
upgrade.
Conflicts and turning the leader off
Two consequences of the stock <Leader> defaults worth knowing:
- Rebinding a
<Leader>chord that a stock default already uses (e.g.split-vertical-pane = "<Leader>h", which collides with the defaultselect-left-pane = "<Leader>h") is a startup validation error naming both actions. Unbind the stock default explicitly (select-left-pane = "") or pick a free chord. leader = ""disables every<Leader>-bound action at once — with the stock defaults that includes all 13 leader-bound actions above, silently (a warning is logged, but startup succeeds). If you disable the leader, rebind the actions you need to direct chords, e.g.split-vertical-pane = "Ctrl+Shift+I".
The + and - keys
Ctrl++ is not a valid value: a chord is split on +, so write Ctrl+Plus.
A Plus binding also fires with Shift held, since + is Shift+= on a US
layout. Key bindings match physical key positions, so on a non-US layout the
Plus and - positions may not be where the labels are. The numeric keypad’s
+ and - are not bindable. Plus resolves to the physical position of the
= key on a US layout, and fires whether or not Shift is held — including
when it is the leader. On a layout with a dedicated + key, such as German,
that position is a different key, so bind the key you actually want by name
instead.
Example
The stock [shortcuts] table, with the macOS defaults:
[shortcuts]
# NOTE: the values in this block are the macOS defaults. Seven of them differ on
# Windows and Linux — see "Platform defaults" above for the other table.
# The leader for "<Leader>..." bindings. Either a full chord ("Ctrl+A": press
# the chord, then the next key) OR a bare modifier to TAP ("Cmd"/"Ctrl"/"Alt":
# tap the modifier with no other key, then the next key). Defaults to "Cmd" on
# macOS and "Alt" elsewhere, and is active only when at least one action is
# bound to "<Leader>..." — the stock defaults below already bind thirteen
# actions to "<Leader>...", so the tap leader is armed out of the box.
# Set "" to disable it. "Shift" is not allowed as a tap.
leader = "Cmd"
# Modifier-tap window (ms): a press+release within this time, with no intervening
# key or mouse press, counts as a tap. Default 300; 0 reverts to 300.
leader-tap-timeout-ms = 300
# Repeat window (ms) for "<Leader:r>..." bindings: after such a binding fires,
# pressing a repeat-marked key again within this window re-fires the action
# without the leader. Each fire re-arms the window. Default 500; 0 disables
# repeat entirely.
repeat-time-ms = 500
# Each action takes ONE value: a direct chord ("Cmd+V"), a leader-scoped
# chord ("<Leader>s" = leader then s), a repeatable leader-scoped chord
# ("<Leader:r>s" = same, but re-fires within repeat-time-ms), or "" to unbind.
# Rebinding to a chord already used by another action is a startup validation
# error. A direct chord and a "<Leader>"-prefixed chord with the same key
# never collide.
# --- existing actions ---
paste = "Cmd+V" # Standard terminal paste; set paste = "<Leader>v" for a leader binding.
copy = "Cmd+C" # Copy the focused terminal's selection to the system clipboard.
release-webview-focus = "<Leader>u"
quit = "Cmd+Q" # Unbound by default off macOS, where the window manager closes the window.
enter-vi-mode = "<Leader>s" # Enters vi mode.
# --- pane actions ---
select-left-pane = "<Leader>h" # select-pane -L
select-down-pane = "<Leader>j" # select-pane -D
select-up-pane = "<Leader>k" # select-pane -U
select-right-pane = "<Leader>l" # select-pane -R
split-vertical-pane = "<Leader>i" # split-window -h (side-by-side)
split-horizontal-pane = "<Leader>o" # split-window -v (stacked)
kill-pane = "<Leader>p" # kill-pane
resize-left-pane = "<Leader:r>Shift+H" # resize-pane -L 5 (repeatable)
resize-down-pane = "<Leader:r>Shift+J" # resize-pane -D 5 (repeatable)
resize-up-pane = "<Leader:r>Shift+K" # resize-pane -U 5 (repeatable)
resize-right-pane = "<Leader:r>Shift+L" # resize-pane -R 5 (repeatable)
# --- zoom actions ---
increase-font-size = "Cmd+Plus" # Ctrl+Plus off macOS
decrease-font-size = "Cmd+-" # Ctrl+- off macOS
reset-font-size = "Cmd+0" # Ctrl+0 off macOS
Building Webview Apps
This page walks through writing a terminal app that shows a web page, with
ratatui_orzma, the Rust SDK, and through
having the page talk back with
@orzma/web. Each step is a
complete example from the repository.
How it fits together
flowchart LR
program["Your program<br/>(ratatui + ratatui_orzma)"]
orzma(["orzma"])
page["Web page<br/>(window.orzma)"]
program -->|"register content and place it"| orzma
orzma -->|"draws the page in the program's cells"| page
program <-->|"calls and events"| orzma
orzma <-->|"calls and events"| page
Your program runs inside an orzma pane and draws its interface with ratatui, leaving a rectangle of cells for the page. The SDK registers the page with orzma, places it wherever the widget is drawn, and carries messages between your program and the page.
Setup
Add the SDK, the version of ratatui it is built against, and serde:
cargo add ratatui_orzma ratatui@0.29
cargo add serde --features derive
Your app must use the same ratatui version as ratatui_orzma, which is built
against ratatui 0.29; with another version, the SDK’s widget and backend types
do not match yours. The examples use let chains, which need Rust 1.88 or later
and the 2024 edition.
The app has to run inside an orzma pane. orzma sets ORZMA_SOCK and
ORZMA_TOKEN in every pane, and Orzma::connect returns an error when they
are missing.
The examples below live in
sdk/ratatui_orzma/examples.
To run one as it is, clone the repository and run
cargo run -p ratatui_orzma --example <name> inside an orzma pane.
Step 1: Show a page
simple registers a small HTML document and draws it below a one-line hint.
//! Minimal orzma webview render. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example simple`.
//!
//! Registers a tiny HTML page (`simple.html`, embedded via `include_str!`) and
//! renders it as a ratatui widget filling the pane below a one-line hint. This is
//! the whole render path: connect → register → draw. Press `q` to quit.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::Duration;
const HTML: &str = include_str!("simple.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML))?;
common::run(&orzma, |terminal| {
loop {
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(Paragraph::new("simple webview · q to quit"), rows[0]);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;background:#13131a;color:#8be9fd;font:20px sans-serif">
Hello from an orzma webview
</body>
Orzma::connectopens the connection to orzma. Call it once at startup.orzma.register(Webview::inline(HTML))registers the page and returns a handle. Registering waits for orzma’s reply, so do it before the draw loop.WebviewWidget::new(view.instance_id())marks where the page goes. Render it with&mut *orzma.frame()as its state, like any stateful ratatui widget. Thefallbackwidget is drawn in the same cells, and orzma draws cell text over the page, so the fallback stays visible after the page appears.
The terminal setup that the examples share wraps the crossterm backend in
OrzmaBackend. On every draw, the backend tells orzma where each page is, so
the page follows your layout:
//! Shared terminal setup/teardown for the ratatui_orzma examples.
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui_orzma::{Orzma, OrzmaBackend};
use std::error::Error;
use std::io::{Stdout, stdout};
/// The concrete backend the examples draw through: orzma wrapping crossterm.
pub(crate) type Backend = OrzmaBackend<CrosstermBackend<Stdout>>;
/// Runs `body` with a live orzma-backed terminal, restoring the terminal on exit.
pub(crate) fn run<F>(orzma: &Orzma, body: F) -> Result<(), Box<dyn Error>>
where
F: FnOnce(&mut Terminal<Backend>) -> Result<(), Box<dyn Error>>,
{
enable_raw_mode()?;
let _guard = TerminalGuard;
execute!(stdout(), EnterAlternateScreen)?;
let backend = OrzmaBackend::new(CrosstermBackend::new(stdout()), orzma);
let mut terminal = Terminal::new(backend)?;
body(&mut terminal)
}
/// Restores the terminal (raw mode off, leave alternate screen) on drop, so
/// teardown runs unconditionally — including when a fallible setup step or `body`
/// errors or panics.
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen);
}
}
Webview::inline serves one HTML document. Webview::dir(root, entry) serves
a directory of files, such as a bundled frontend, from an absolute root path,
and Webview::url(url) loads a remote http or https page.
Step 2: Exchange events
events sends a counter to the page every second, and the page sends a message
back every second.
//! Event round-trip between the app and a webview (no call/reply — see the `rpc` example — and no focus).
//! Run inside an orzma pane: `cargo run -p ratatui_orzma --example events`.
//!
//! Two one-way event channels form a loop:
//! - app → page: the app emits a `tick` counter each second; the page's
//! `window.orzma.on('tick', …)` shows it.
//! - page → app: the page's `setInterval` calls `window.orzma.emit('hello', …)`;
//! the app drains `view.read_events::<Hello>()` into its status line.
//!
//! No keyboard focus is involved — the page's JS, `window.orzma.on`, and
//! `window.orzma.emit` all run regardless of focus, so the app keeps the keyboard
//! and `q` quits immediately. The widget is still rendered every frame: that is what
//! keeps the page MOUNTED, and both `emit` directions are mount-scoped (a no-op when
//! nothing is mounted).
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::{Duration, Instant};
#[derive(serde::Deserialize)]
struct Hello {
message: String,
}
const HTML: &str = include_str!("events.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML).add_event::<Hello>("hello"))?;
let mut last_msg = String::from("(none yet)");
let mut n: u64 = 0;
let mut last_tick = Instant::now();
common::run(&orzma, |terminal| {
loop {
for Hello { message } in view.read_events::<Hello>() {
last_msg = message;
}
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!("events · q to quit · last: {last_msg}")),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if last_tick.elapsed() >= Duration::from_secs(1) {
n += 1;
let _ = view.emit("tick", &n);
last_tick = Instant::now();
}
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;padding:10px;background:#13131a;color:#8be9fd;font:14px sans-serif">
<div id="tick">waiting for tick…</div>
<script>
window.orzma.on('tick', (n) => {
document.getElementById('tick').textContent = `tick #${n}`;
});
let i = 0;
setInterval(() => {
i += 1;
window.orzma.emit('hello', { message: `from page #${i}` });
}, 1000);
</script>
</body>
view.emit(name, &payload)sends an event to the page, wherewindow.orzma.on(name, handler)receives it.- The page sends an event with
window.orzma.emit(name, payload). Declare the event withWebview::add_event::<T>(name)when you register the page, and read the events that arrived withview.read_events::<T>()in your loop. - Events are delivered only while the page is on screen, so keep rendering the widget.
Step 3: Handle calls from the page
rpc answers two methods that the page calls: add returns a sum, and
divide fails when the divisor is zero.
//! Call/reply RPC between a webview and the app. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example rpc`.
//!
//! The page calls two app methods through `window.orzma.call` once a second:
//! - `add` resolves with the sum of its two operands;
//! - `divide` rejects with an `RpcError` when the divisor is zero, which the
//! page receives as a rejected Promise.
//!
//! Handlers run on the SDK's reader thread, not in the draw loop, so the count
//! of answered calls that the status line shows is shared through an atomic.
//! The view is registered with `.interactive(false)`: a click on the page never
//! takes keyboard focus, so `q` always reaches the app.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{Orzma, RpcError, Webview, WebviewWidget};
use std::error::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
#[derive(serde::Deserialize)]
struct Operands {
a: f64,
b: f64,
}
const HTML: &str = include_str!("rpc.html");
fn main() -> Result<(), Box<dyn Error>> {
let answered = Arc::new(AtomicU64::new(0));
let add_answered = Arc::clone(&answered);
let add = move |Operands { a, b }: Operands| -> Result<f64, RpcError> {
add_answered.fetch_add(1, Ordering::Relaxed);
Ok(a + b)
};
let divide_answered = Arc::clone(&answered);
let divide = move |Operands { a, b }: Operands| -> Result<f64, RpcError> {
divide_answered.fetch_add(1, Ordering::Relaxed);
if b == 0.0 {
return Err(RpcError::new("division by zero"));
}
Ok(a / b)
};
let orzma = Orzma::connect()?;
let view = orzma.register(
Webview::inline(HTML)
.interactive(false)
.on("add", add)
.on("divide", divide),
)?;
common::run(&orzma, |terminal| {
loop {
let calls = answered.load(Ordering::Relaxed);
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!("rpc · q to quit · calls answered: {calls}")),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("loading…")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
&& k.code == KeyCode::Char('q')
{
return Ok(());
}
}
})
}
<body style="margin:0;padding:10px;background:#13131a;color:#8be9fd;font:14px sans-serif">
<div id="sum">add: waiting…</div>
<div id="quotient">divide: waiting…</div>
<script>
const sum = document.getElementById('sum');
const quotient = document.getElementById('quotient');
let n = 0;
const tick = () => {
n += 1;
const a = n;
window.orzma.call('add', { a, b: 2 }).then(
(value) => {
sum.textContent = `add(${a}, 2) = ${value}`;
},
(error) => {
sum.textContent = `add(${a}, 2) failed: ${error.message}`;
},
);
const b = a % 3;
window.orzma.call('divide', { a, b }).then(
(value) => {
quotient.textContent = `divide(${a}, ${b}) = ${value}`;
},
(error) => {
quotient.textContent = `divide(${a}, ${b}) failed: ${error.message}`;
},
);
};
tick();
setInterval(tick, 1000);
</script>
</body>
Webview::on(method, handler)answerswindow.orzma.call(method, params). The page’sparamsvalue is deserialized into the handler’s argument type, and the handler’s return value is what the page’s Promise resolves with.- Returning
Err(RpcError::new(message))rejects the page’s Promise with anErrorcarrying that message. - Handlers run on the SDK’s background thread, not in your draw loop. Share
state with the rest of your app through types such as
Arc<AtomicU64>orArc<Mutex<T>>, and keep handlers short. .interactive(false)registers a page that takes no mouse or keyboard input, so a click on it never takes the keyboard away from your app.
Step 4: Share the keyboard
A click on an interactive page gives it keyboard focus: from then on, keys go
to the page, not to your app. forward_keys shows how an app hands the
keyboard to the page and takes it back.
//! Forwarding keys out of a focused webview. Run inside an orzma pane:
//! `cargo run -p ratatui_orzma --example forward_keys`.
//!
//! A webview plus a native status line, with app-owned focus in a `web_focused`
//! bool. `Alt+l` focuses the webview (bare keys then type into its input); `Alt+h`
//! returns focus to the app; `q` quits while the app is focused.
//!
//! Only `Alt+h` is declared as a forward-key, and that asymmetry is the point:
//! `Alt+h` is pressed WHILE the page holds keyboard focus, so without forwarding it
//! would be swallowed by the page and focus could never leave the webview — the host
//! forwards the declared chord to the app, and only to the app, so `event::read`
//! sees it even while the page is focused. `Alt+l` is pressed while the app still
//! owns the keyboard, so it already reaches `event::read` and needs no declaration.
//!
//! `WebviewHandle::focus` and `Orzma::blur` send the control-plane focus op, and
//! `WebviewHandle::read_focus_changes` reports a click that focused the page.
#[path = "common/terminal.rs"]
mod common;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui_orzma::{KeyChord, Orzma, Webview, WebviewWidget};
use std::error::Error;
use std::time::Duration;
const HTML: &str = include_str!("forward_keys.html");
fn main() -> Result<(), Box<dyn Error>> {
let orzma = Orzma::connect()?;
let view = orzma.register(Webview::inline(HTML).forward_keys([KeyChord {
mods: KeyModifiers::ALT,
code: KeyCode::Char('h'),
}]))?;
let mut web_focused = false;
common::run(&orzma, |terminal| {
loop {
for change in view.read_focus_changes() {
web_focused = change.focused;
}
terminal.draw(|f| {
let rows =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(f.area());
f.render_widget(
Paragraph::new(format!(
"Alt+l focus webview · Alt+h leave · q quit · focus: {}",
if web_focused { "webview" } else { "app" }
)),
rows[0],
);
f.render_stateful_widget(
WebviewWidget::new(view.instance_id())
.fallback(Block::bordered().title("webview")),
rows[1],
&mut *orzma.frame(),
);
})?;
if event::poll(Duration::from_millis(50))?
&& let Event::Key(k) = event::read()?
{
match (k.modifiers, k.code) {
(KeyModifiers::ALT, KeyCode::Char('l')) => {
view.focus()?;
}
(KeyModifiers::ALT, KeyCode::Char('h')) => {
orzma.blur()?;
}
(KeyModifiers::NONE, KeyCode::Char('q')) if !web_focused => return Ok(()),
_ => {}
}
}
}
})
}
<body style="margin:0;height:100vh;box-sizing:border-box;background:#10121a;color:#8be9fd;font:14px sans-serif;display:flex;flex-direction:column;gap:8px;padding:10px">
<div>type here — bare keys reach the focused webview:</div>
<input id="in" placeholder="..." style="font:14px monospace;padding:6px;background:#1b1e2b;color:#e7e7ef;border:1px solid #8be9fd;border-radius:4px">
<div style="opacity:.7">Alt+h returns focus to the app</div>
<script>
var i = document.getElementById('in');
i.focus();
window.addEventListener('focus', function () {
i.focus();
});
</script>
</body>
view.focus()gives the page keyboard focus, andorzma.blur()gives it back to the terminal.Webview::forward_keyslists chords that reach your app even while the page has focus; every other key goes to the page. Replace the list later withview.set_forward_keys.view.read_focus_changes()reports every focus change, including a click on the page.- Users can always take the keyboard back with the
release-webview-focusshortcut (<Leader>uby default; see Key Bindings).
The page side
orzma injects window.orzma into every page registered with Webview::inline
or Webview::dir. A Webview::url page gets it only when the app opts in with
.bridge(true), or registers a handler with on or an event with add_event.
For TypeScript, @orzma/web adds types to the bridge:
npm install @orzma/web
import { isOrzmaAvailable, orzma } from '@orzma/web';
if (isOrzmaAvailable()) {
orzma.on<number>('tick', (n) => {
document.title = `tick ${n}`;
});
orzma.call<number>('add', { a: 1, b: 2 }).then((sum) => {
orzma.emit('hello', { message: `1 + 2 = ${sum}` });
});
}
| Function | What it does |
|---|---|
orzma.call<R>(method, params?) | Calls a method in the app and resolves with its reply; rejects with an Error when the app returns an error. There is no timeout. |
orzma.on<P>(event, handler) | Runs handler for every event with that name from the app. |
orzma.off<P>(event, handler) | Removes a handler added with on. |
orzma.emit<P>(event, payload?) | Sends a one-way event to the app. |
isOrzmaAvailable() | Reports whether the page has the bridge. |
Next steps
- The full API is on docs.rs.
- The Webview Protocol page describes the wire protocol, for writing a client in another language.
Webview Protocol
orzma is in early development; this wire format is documented as it is today and may change between releases. The SDKs track these changes for you — prefer them unless you are implementing your own client.
Overview
The Orzma Webview protocol lets a local program running inside an orzma pane render webview content inline in the terminal and exchange messages with the page. It spans three surfaces:
- The control socket — a local Unix-socket connection over which a program registers content, manages it, and routes the page back-channel.
- APC verbs — terminal escape sequences that mount and unmount registered content at a cell rectangle.
- The
window.orzmabridge — an in-page JavaScript API the webview uses to call, subscribe to, and emit events to the registering program.
Three actors participate: the registering program (running in a pane), the orzma host, and the webview page. A registration is a Tier 1 (dynamic, runtime-registered) webview — the only kind this protocol describes.
End to end: a program connects to the control socket, registers content and
receives an opaque handle together with its first placement instance,
writes an ESC _ Omount;n=<instance>,… sequence to display it, and then talks to
the page through the window.orzma bridge routed over the same control socket.
Unmounting (or disconnecting) tears it down.
Architecture at a glance
sequenceDiagram
participant P as Registering program
participant H as orzma host
participant W as Webview page
Note over P: reads $ORZMA_SOCK and $ORZMA_TOKEN
P->>H: hello {token}
P->>H: register {kind, …}
H-->>P: {ok, handle, instance}
P->>H: APC Omount#59;n=instance,r=rows,c=cols
H->>W: load orzma://handle/…
W->>H: window.orzma.call
H->>P: {op: call, reqId, method}
P->>H: {op: reply, reqId, value}
H->>W: resolve the Promise
P->>H: {op: emit, event, payload}
H->>W: run window.orzma.on handlers
W->>H: window.orzma.emit
H->>P: {op: event, …}
P->>H: APC Ounmount#59;n=instance
H->>W: remove the webview
The control socket carries every message between the program and the host, the APC verbs carry the mount and unmount, and the page bridge carries the window.orzma messages between the host and the page.
The control socket
Transport
The control socket is a local Unix-domain stream socket speaking NDJSON
(on Windows, an AF_UNIX socket, available since Windows 10 1809; the endpoint
is a filesystem path on every platform):
exactly one JSON object per line, terminated by \n (a trailing \r is
tolerated). Each line travels in one direction. The host closes a connection
when its first line is longer than 4 KiB or any later line is longer than
32 MiB. The connection is long-lived — it stays open for as long as the
program wants its registrations to live.
Discovery
orzma injects two environment variables into every pane’s PTY. A program reads them from its own environment:
$ORZMA_SOCK— the absolute path to the control socket. Connect to this path verbatim; do not reconstruct it.$ORZMA_TOKEN— the per-pane handshake token. Treat it as opaque (it is currentlyorzma:followed by 26 lowercase base32 characters, but do not parse it).
If either variable is absent, the program is not running inside an orzma pane, or orzma could not open its control socket, and the program cannot use the protocol.
Peer authentication
The host restricts the control socket to orzma’s own user. On Unix it checks that the connecting peer’s user id equals its own and silently drops the connection otherwise. On Windows the socket lives in a directory whose DACL grants access to the current user only, so other users cannot connect at all. Either way, only processes running as the same user can reach the handshake.
Handshake
The first line a program sends MUST be a hello carrying $ORZMA_TOKEN:
{ "op": "hello", "token": "orzma:mzxw6ytboi3tmnrqgq2tgmzvgm" }
The token binds the connection to the pane it was issued for. If the first line
is not a valid hello, or the token does not resolve, the host closes the
connection without a reply. A successful hello gets no reply either; the
program may send requests right after it. A second hello on an
already-handshaked connection is ignored.
Reply vs. push
After the handshake, two kinds of line arrive from the host on the same connection, and a client must tell them apart:
- A request reply is the only host line with no
opfield. Bothregisterandnew_instanceare answered this way, and either can also reply{"ok":false,"error":"…"}. - Every host-initiated push (
call,event,compositing,focus_changed) carries anopfield.
So: a line with an op is a push; a line without one is the reply to your
oldest outstanding request. This is the one framing rule a from-scratch client
must get right.
Request ordering
register and new_instance are processed one at a time per connection, and
each reply arrives in request order. Neither carries a request id, so
correlation is positional: a client matches replies to its pending requests by
their order. (The back-channel call/reply pair below uses an explicit
reqId instead.)
After the handshake, the host skips a line that is not valid JSON, names an
unknown op, or lacks or mistypes a required field, without replying and
without closing the connection; unknown extra fields are ignored. A malformed
register or new_instance therefore gets no reply, so a client that matches
replies by position must send only well-formed requests.
Program → host messages
Every program line carries an op:
op | Fields | Meaning |
|---|---|---|
hello | token | Handshake; first line only. |
register | kind + per-kind fields | Register content; mints a handle and its first instance. |
new_instance | handle | Mint an additional instance on a handle this connection owns. |
unregister | handle | Release a handle owned by this connection; removes its mounted views. |
reply | reqId, ok, value?, error? | Answer a host call (use the call’s reqId). |
emit | handle, event, payload | Push an event to every page mounted from the handle (delivered to window.orzma.on). Ignored for a url view without the bridge. |
focus | instance (string or null) | Set app-owned focus to a mounted, interactive placement, or null to blur. Focusing a placement also makes its pane the active pane; a blur leaves the active pane unchanged. |
navigate | instance, action | Navigate one mounted placement in place. |
mount | instance, row, col, rows, cols | Mount one placement at a 0-based cell of the pane’s active screen, the socket form of the APC mount (see below). |
unmount | instance | Remove one placement, whether the socket mount or the APC mount placed it. |
set_forward_keys | handle, keys (array of chords) | Replace the forward keys of a handle this connection owns, on the registration and on every mounted placement; later mounts carry the new list. No reply; a handle this connection does not own is ignored. |
Only register and new_instance are answered. The host silently ignores any
other op it refuses, such as one naming a handle or instance this connection
does not own.
navigate.action is one of the strings "back", "forward", "reload", or
the object {"to":"<http(s) url>"} (to is valid only on a url view).
Register kinds
register carries a kind discriminator and its fields:
kind | Required | Optional (default) | Served at |
|---|---|---|---|
dir | root (absolute dir path), entry (safe relative path, e.g. index.html) | interactive (true), forward_keys ([]), preload ([]) | orzma://<handle>/<entry> |
inline | html (full document, ≤ 4 MiB) | interactive (true), forward_keys ([]), preload ([]) | orzma://<handle>/index.html |
url | url (http/https only) | interactive (true), bridge (false), forward_keys ([]), preload ([]) | the remote URL directly (no orzma:// origin) |
interactive— whether the mounted view accepts pointer/keyboard input.forward_keys— the initial forward keys; replace them later withset_forward_keys.bridge(urlonly) — opt into thewindow.orzmaback-channel.dirandinlineare always bridged; aurlview is bridged only withbridge:true.preload— an array of JavaScript source strings injected before the page’s own scripts, after the host bridge when the view is bridged. It is honored for every kind, including aurlview without the bridge.
Focus
Outside vi mode, a pointer press inside a mounted interactive view’s rect
gives that view keyboard focus and makes its pane the active pane, and a press
on the terminal outside every view’s rect gives the keyboard back to the
terminal. While a view holds focus, the host delivers keys to the page, except
the view’s forward keys, which go to the pane’s PTY instead,
and orzma’s <Leader> shortcuts and release-focus shortcut, which still run.
A focus op moves focus to a mounted, interactive placement this connection
owns, or, with null, takes it back from whichever view in this connection’s
pane holds it. A focus naming an unmounted or non-interactive placement is
ignored. The active pane does not change on a blur.
Every change is reported to the program that registered the placement with a
focus_changed push, whatever caused it: a click, a focus op, vi mode,
the release-focus shortcut, a pane switch, or an unmount. A program whose
TUI needs every key for a while — a search line, an address bar — sends a
focus op with null when it starts and, if it wants, focuses the page
again when it ends.
Forward keys
forward_keys lists key chords the host passes through to the pane’s PTY
instead of letting the focused webview consume them: a matching key reaches
the program and never the page. register carries the initial list;
set_forward_keys replaces it wholesale. Each chord is:
{ "mods": ["alt"], "key": "h" }
mods is any subset of "alt", "ctrl", "shift", "meta". key is one of:
- a lowercase letter
a–z, a digit0–9,tab,backtab,f1–f12,esc," "(space),up,down,left,right,pageup,pagedown,home,end,enter,backspace,delete— matched against the physical key with the exact modifier set.backtabnames the same key astab, so include"shift"inmodsto match Shift+Tab; - one ASCII punctuation character such as
/,?,[,],:— matched against the character the key produced, whichever key produced it, with Shift ignored and the other modifiers exact. Characters typed through a dead key or AltGr do not match. On macOS, a character typed with the Option key does not match a punctuation chord unlessoption_as_altis in effect for that side.
Unrecognized chords are silently ignored. The key-up of a forwarded key may still reach the page.
Host → program messages
Every host push carries an op:
op | Fields | Meaning / response |
|---|---|---|
call | handle, instance, reqId, method, params | A page window.orzma.call(method, params). Respond with a reply carrying the same reqId. |
event | handle, event, payload | A page window.orzma.emit(event, payload). Fire-and-forget; no response. |
compositing | handle, instance, active (bool) | The placement first appeared on screen (true), or was unmounted after that (false). Only a view with the bridge gets this push. |
focus_changed | handle, instance, focused (bool) | The placement gained (true) or lost (false) keyboard focus. A move between placements sends false for the old one before true for the new one. |
call names the instance whose page called; event does not, because a
program reads events per handle rather than per placement. A program running
two placements of one handle can therefore tell which page called it, but not
which page emitted an event.
Two directional details that are easy to get wrong:
emitvs.event. A page’swindow.orzma.emit(name, …)arrives at the program asop:"event". A program’s ownemitmessage (op:"emit") is delivered to pages’window.orzma.on(name, …). Same idea (“named event”), twoopvalues depending on direction.urlChanged. For aurlview registered withbridge:true, the host reports top-level address changes as anop:"call"withmethod:"urlChanged"andparams:{"url":"<new>"}. Despite thecallshape it is fire-and-forget — anyreplyis discarded. Use it to track page-driven navigation.
Request replies & error codes
A successful register replies
{"ok":true,"handle":"<handle>","instance":"<instance>"} — the handle owns the
registration, and the instance is its first placement. A successful
new_instance replies {"ok":true,"instance":"<instance>"}. A rejected request
of either kind replies {"ok":false,"error":"<code>"}:
error | Cause |
|---|---|
invalid_root | dir.root is not an absolute path to an existing directory. |
unsafe_entry | dir.entry is empty or absolute, or contains a .. component or a leading . component. |
html_too_large | inline.html exceeds 4 MiB. |
invalid_url | url.url does not parse or has no host. |
unsupported_scheme | url.url is not http/https. |
unknown_handle | new_instance.handle names no live registration. |
not_owner | new_instance.handle is registered, but by another connection. |
owner_gone | The register request’s pane has already closed. |
internal | The host failed to process the request. |
Handle semantics
A handle is opaque, unique per registration, and lowercase: 128 CSPRNG bits
base32-encoded over the alphabet a-z2-7, which keeps it spellable as a URL
host. That encoding is unpadded, so a handle is always 26 characters. Treat
it as a token: do not parse it. Each handle owns one isolated
orzma://<handle>/ origin, and it is what unregister, emit,
set_forward_keys, and new_instance address. A handle is never mounted — a
mount addresses an instance.
Instance semantics
An instance is one placement of a registration, and it is the unit mount,
unmount, focus, and navigate address. Its wire spelling is exactly 32
lowercase hex digits (128 CSPRNG bits); any other spelling is malformed. Only
the host mints instances — register mints the first, new_instance each
additional one — so uniqueness is structural and nothing on the wire negotiates
it.
An instance stays valid for as long as its handle is registered, whether or not it is currently mounted. Mounting an instance that is already live moves and resizes its rectangle to the new mount and leaves the page untouched; mounting one after it was unmounted builds the page again from scratch. Every instance of a handle serves that handle’s registered content, and each one mounts independently.
Example exchange
Program-to-host lines are marked C→S, host-to-program lines S→C:
C→S {"op":"hello","token":"orzma:mzxw6ytboi3tmnrqgq2tgmzvgm"}
C→S {"op":"register","kind":"inline","html":"<!doctype html><body>hi</body>"}
S→C {"ok":true,"handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","instance":"3f5a9c02d1e84b7690ab3cde12f45678"}
S→C {"op":"call","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","instance":"3f5a9c02d1e84b7690ab3cde12f45678","reqId":"0","method":"save","params":{"text":"hi"}}
C→S {"op":"reply","reqId":"0","ok":true,"value":{"saved":true}}
C→S {"op":"emit","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","event":"tick","payload":{"n":1}}
C→S {"op":"new_instance","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh"}
S→C {"ok":true,"instance":"a1b2c3d4e5f60718293a4b5c6d7e8f90"}
APC webview verbs — mount / unmount
Once an instance is minted, the program mounts it by writing an APC escape
sequence to its terminal. The sequence is framed ESC _ <payload> ST, where
ST (string terminator) is ESC \. Unlike an OSC, a BEL does not terminate
an APC — it is taken as payload data. The payload opens with O (orzma), so a
sequence another program owns — kitty’s G, for example — is left alone. In
raw bytes:
mount: \x1b_Omount;n=<instance>,r=<rows>,c=<cols>\x1b\
unmount: \x1b_Ounmount;n=<instance>\x1b\
The payload is at most 1024 bytes. Keep it to ASCII: the terminal does not
decode UTF-8 inside an APC. It silently drops some bytes of a multi-byte
character, and a byte in the range 0x80–0x9F, which many multi-byte
characters contain, ends the sequence early and shows the rest of it as text.
Socket form
A PTY that re-renders its child’s output rather than passing it through — ConPTY on Windows — drops APC strings, so every host also accepts the same two verbs as control-socket ops:
{"op":"mount","instance":"<instance>","row":<row>,"col":<col>,"rows":<rows>,"cols":<cols>}
{"op":"unmount","instance":"<instance>"}
row and col are the 0-based cell of the pane’s active screen that the
rect’s top-left corner occupies: the cell an APC mount reaches by first moving
the cursor with CUP row+1;col+1. Unlike CUP, they are absolute even when
DECOM origin mode is on. rows and cols obey the APC bounds (1–200,
1–400). The host drops a mount naming an instance the connection does
not own or a size out of range; the terminal refuses a cell outside the grid
or a mount past the per-terminal placement cap exactly as it refuses an APC
mount. The socket unmount names one instance; there is no unmount-all form.
The ratatui_orzma SDK sends the socket form on Windows and the APC form
elsewhere.
mount
ESC _ O mount ; n=<instance>,r=<rows>,c=<cols> ST
instance— an instance fromregisterornew_instance; exactly 32 lowercase hex digits.rows— decimal1–200.cols— decimal1–400. Write plain decimal digits.
All three keys are required and order-independent (c=80,n=<instance>,r=24 is
the same mount as n=<instance>,r=24,c=80). A repeated key, an unknown key —
the retired v= included — a missing n / r / c, or an empty params
section (Omount;) is malformed.
The placement occupies a rows×cols rectangle of terminal cells, inline at
the cursor.
unmount
ESC _ O unmount [ ; n=<instance> ] ST
- No params section → unmount every placement on this terminal, on both screens, whichever program in the pane mounted it.
n=→ unmount that one placement.
n is the only key an unmount accepts, so no key-ordering rule applies. An
empty params section (Ounmount;) is malformed, as are an empty value
(Ounmount;n=) and the retired v= key.
Ownership and malformed sequences
A mount takes effect only in the pane whose $ORZMA_TOKEN registered the
instance’s handle — a program mounts its own instances in its own pane. An
instance the host never minted, or one whose handle belongs to another pane, is
silently dropped, as is any malformed sequence (a bad instance spelling,
out-of-range dimensions, an unknown or repeated key); the host reports no
error.
A mount the terminal accepts but cannot place — the per-terminal placement cap of 12 is full — is also dropped, and the host logs it at debug level. The cap counts both screens, so it is reached before the renderer runs out of overlay slots.
Example
Mount instance 3f5a9c02d1e84b7690ab3cde12f45678 as a 24×80 placement, then
unmount it:
\x1b_Omount;n=3f5a9c02d1e84b7690ab3cde12f45678,r=24,c=80\x1b\
\x1b_Ounmount;n=3f5a9c02d1e84b7690ab3cde12f45678\x1b\
The orzma:// origin
dir and inline registrations are served from a per-handle origin
orzma://<handle>/. A request for an empty path resolves to index.html. The
scheme is standard, secure, CORS-enabled, fetch-enabled, and display-isolated,
so normal fetch, ES modules, and same-origin requests work within the
handle’s origin. Each handle is its own isolated origin.
dir— files are served from the registeredroot. Requests that escape the root — a..or.path component, an absolute path, or their percent-encoded forms — are rejected; each file is capped at 64 MiB; the content type is inferred from the file extension. The check looks at the path only, so a symlink inside the root is followed wherever it points.inline— the single registered document is served only atindex.html; any subresource request returns 404. Usedirfor multi-file content.url— the remotehttp(s)page is loaded directly and has noorzma://origin.
The window.orzma bridge
Bridged webviews expose a frozen window.orzma object to page scripts.
dir and inline views are always bridged; a url view is bridged only when
registered with bridge:true. A page should feature-detect before using it.
API
| Method | Returns | Meaning |
|---|---|---|
call(method, params?) | Promise | Invoke a program method; resolves with the program’s reply value, rejects with Error(error). |
on(event, handler) | void | Subscribe to a program emit. |
off(event, handler) | void | Remove a handler by reference. |
emit(event, payload?) | void | Send a one-way event to the program (arrives as op:"event"). |
A call has no client-side timeout — if the program never replies, the
Promise stays pending. The host injects a rejection when it cannot route the
call: no_owner (the page has no bridge, or its placement was unmounted),
owner_unavailable (the program’s connection can no longer be written to, or
orzma’s multiplexer has stopped), or owner_disconnected (the program
disconnected with the call in flight). A call whose method is not a string
rejects at once with a TypeError.
The bridge subscribes one handler of its own: a program emit named scroll
with the payload {"action":"<action>"}, where <action> is down, up,
halfDown, halfUp, pageDown, pageUp, top, or bottom, scrolls the
page. It does nothing when the page has registered its own scroll handler.
Binary round-trip
A top-level Uint8Array round-trips through the bridge — it is tagged
{"__u8":"<base64>"} on the wire and decoded back to a Uint8Array. This
applies to a call’s params, a resolved value, and an event payload.
Bytes nested inside an object or array are not tagged and are silently
lost. Pass binary as the top-level value, not as a field.
Example
Using the @orzma/web client:
import { orzma, isOrzmaAvailable } from "@orzma/web";
if (isOrzmaAvailable()) {
// Request / response — annotate the reply type.
const res = await orzma.call<{ saved: boolean }>("save", { text: "hi" });
// Subscribe to a program event — annotate the payload.
orzma.on<{ n: number }>("tick", (payload) => console.log(payload.n));
// One-way event to the program.
orzma.emit("ready", { ok: true });
}
Lifecycle & teardown
unregister{handle}releases a handle, invalidates every instance minted from it, and removes its mounted views.- Closing the control connection purges all of that program’s handles, removes
their views, and rejects every in-flight
callwithowner_disconnected. The host treats the end of the program’s sending side as a close, so a program must not shut down its write half while it wants its registrations to live. - When the pane a program runs in closes, the host releases every registration
made from that pane and removes its views, although the program’s connection
stays open. The program gets
compositingfalseandfocus_changedfalsefor the placements that had them. Afterwards aregisterfails withowner_gone, and requests naming the released handles or instances are ignored or fail withunknown_handle. - The terminal also unmounts a placement on its own when the row it is anchored
to leaves the terminal (it scrolls out of the scrollback, the terminal is
reset, or a resize drops it), and when the program leaves the alternate
screen the placement was mounted on. The instance stays valid and can be
mounted again. The program learns of this only through
compositingfalse(for a bridged view) andfocus_changedfalse(if the view held focus). - For a view with the bridge, the
compositingpush reports the first time one placement appears on screen (active:true), which can be before the page has painted, and its unmount after that (active:false). It names both thehandleand theinstance.
Security model
- Same user only. The host restricts the control socket to orzma’s own user: by peer user id on Unix, by the socket directory’s DACL on Windows.
- Scoped to one pane. A connection’s token binds it to the pane that issued
$ORZMA_TOKEN. Control-socket ops act only on registrations the connection made itself; an APCmounttakes effect only in the pane that registered the instance’s handle, and an APCunmountacts on any placement in the pane it is written to. - Unguessable, isolated identifiers. Tokens, handles, and instances are all
128-bit CSPRNG values, and each handle is its own
orzma://origin. - Authorized replies. Back-channel
reqIds are a shared, monotonic counter and therefore guessable, so the host authorizes areplyby its originating connection: a program replaying another connection’sreqIdcan neither settle nor drop that call.
SDKs
Prefer a ready-made client over implementing the wire protocol directly:
ratatui_orzma— Rust SDK for the program side (a ratatui widget plus a back-channel RPC handler).@orzma/web— TypeScript client for the page-sidewindow.orzmabridge.
Building Webview Apps walks through both.
Contributing to orzma
Thanks for helping! This guide covers the development setup, how the code is organized, the conventions the code follows, how to send a change, and how to work on the documentation.
Development setup
orzma builds on macOS, on Windows 10 1809+ / 11 (x64), and on Linux (x86_64, glibc 2.35 or later).
You need:
- Rust, installed with rustup. The repository pins the
toolchain (1.95) in
rust-toolchain.toml, and rustup installs it on first use. - Node.js 22 or later, and pnpm 10.30.2 (the
packageManagerinpackage.json):npm install -g pnpm@10.30.2. - just.
- On Windows, CMake and Ninja:
winget install Kitware.CMake Ninja-build.Ninja. - On Linux, the build packages the release build uses — on Ubuntu or Debian,
sudo apt install build-essential pkg-config libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev libfontconfig1-dev— and the runtime libraries that Getting Started lists for the tarball.
Then, from the repository root:
pnpm install
just setup-cef # one-time: installs the Chromium Embedded Framework and its render process
just run # builds and runs orzma
| Command | What it does |
|---|---|
just build | Build the workspace. |
just run | Run orzma. |
just test | Run every Rust test. |
pnpm -r test | Run the TypeScript tests. |
pnpm check-types | Type-check the TypeScript packages. |
just fix-lint | Apply clippy fixes, rustfmt, and biome fixes. |
just install-apps | Build and install orzmd and orzbrowser. |
On Windows, just build, just run, and just test set CEF_PATH for you.
When you run cargo yourself — including through just fix-lint — set
CEF_PATH to %USERPROFILE%\.cache\orzma\cef first.
Architecture
orzma is a single native GUI application. Terminal emulation, GPU rendering, the multiplexer, input, and webview rendering all run in one Bevy app; there is no daemon, HTTP server, or browser-side frontend.
flowchart TD
orzma["orzma (the app)"]
webview["bevy_orzma_webview"]
host["orzma_webview_host"]
renderer["bevy_orzma_tty_renderer"]
bevy_orzmux["bevy_orzmux"]
orzmux["orzmux"]
tty["orzma_tty"]
vt["orzma_vt"]
configs["orzma_configs"]
cef["bevy_cef (crates.io)"]
sdk["ratatui_orzma (SDK)"]
orzmd["orzmd"]
orzbrowser["orzbrowser"]
orzma --> webview
orzma --> configs
webview --> renderer
webview --> cef
renderer --> bevy_orzmux
bevy_orzmux --> orzmux
orzmux --> tty
orzmux --> host
tty --> vt
host --> vt
orzmd --> sdk
orzbrowser --> sdk
Arrows point from a crate to the crates it uses; a dependency already implied by a path through other crates is left out.
| Crate | Role |
|---|---|
orzma (src/) | The app: window, input, UI, and the plugins below. |
orzma_vt | Terminal emulation: the screen, scrollback, escape sequences, and selection. |
orzma_tty | Runs the shell under a PTY and drives orzma_vt. |
orzmux | The multiplexer backend: a thread that owns every pane and the pane layout. |
bevy_orzmux | Connects the backend to the Bevy app. |
bevy_orzma_tty_renderer | Draws the terminal grid on the GPU. |
orzma_webview_host | The webview server: the control socket and every program’s registrations, placements, and focus. |
bevy_orzma_webview | Draws webviews with CEF, serves their content through the orzma:// scheme, and relays the window.orzma bridge. |
orzma_configs | Loads config.toml. |
ratatui_orzma (sdk/) | The Rust SDK for webview apps. @orzma/web is its page-side companion. |
At run time, the orzmux thread owns each pane’s PTY and terminal state and runs
the webview host. The app sends it commands and receives layout snapshots,
frames, and webview events, which bevy_orzma_tty_renderer and
bevy_orzma_webview draw. Programs in a pane reach the webview host through the
webview protocol.
Conventions
- Code comments are written in English.
- Rust code follows .claude/rules/rust.md, and TypeScript follows .claude/rules/typescript.md. They cover the comment rules, doc comments, imports, and error handling.
- Run
just fix-lintbefore sending a change. CI runscargo fmt --check,cargo clippywith warnings as errors, the tests,cargo doc, cargo-deny, the third-party license check, andpnpm lint:ci. - Commit messages follow the Conventional Commits style of the history, for
example
feat(webview): …,fix(input): …, ordocs: ….
Pull requests
Open a pull request against main and fill in the template. Release notes are
grouped by label, so add one of breaking-change, enhancement,
performance, bug, documentation, or dependencies — or skip-changelog
to leave the change out of the notes.
Documentation
The user guide lives in docs/book and is built with
mdBook. It is published to
https://not-elm.github.io/orzma/ when a release tag is pushed.
just setup-book # one-time: installs the pinned mdBook and mdbook-mermaid
just book-serve # preview with live reload
just book # build into target/book
- When a change affects what users see — a configuration key, a shortcut, or
the webview protocol — update the matching page under
docs/book/srcin the same pull request. - Draw diagrams with Mermaid in a code block tagged
mermaid. In asequenceDiagram, write a;inside a message as#59;. - Link to repository files from the book with absolute URLs such as
https://github.com/not-elm/orzma/blob/main/...; the published book cannot follow relative links out ofdocs/book. - To upgrade mdBook or mdbook-mermaid, change the versions in the
justfile. After upgrading mdbook-mermaid, deletedocs/book/mermaid.min.jsanddocs/book/mermaid-init.jsand runmdbook-mermaid install docs/book, because it does not overwrite existing files. The lychee version is pinned in.github/workflows/book.yml. - When the SDK moves to a new ratatui version, update the version in the Setup
section of
docs/book/src/building-webview-apps.mdand insdk/ratatui_orzma/README.md.