Introduction
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.
This guide describes orzma 0.6.1.
Features
- Web pages in the terminal. A program places web pages among its own text and exchanges messages with them. A Rust SDK does the protocol work for the program, and a TypeScript package types the page’s side.
- Panes and tabs. Split the window into panes, and keep several layouts as tabs in a tab bar.
- Vi mode. Move over the screen and the scrollback with vi keys, and select and copy text without the mouse.
- Companion apps.
orzmdshows Markdown andorzbrowserbrowses the web, each inside a pane. - One settings file. Fonts, the cursor, the mouse, and every shortcut are set in one TOML file.
Where to go next
- To install orzma, see Installation. If you are upgrading from an earlier release, see Upgrading.
- To learn the everyday keys, follow First Steps.
- To change settings or shortcuts, see Configuration and Key Bindings.
- To write a terminal app that shows a web page, see Building Webview Apps.
The changes in each release are listed on the releases page.
Installation
If you are upgrading from an earlier release, read Upgrading for the changes that need you to edit your configuration.
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.
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
To install without Homebrew, download orzma-<version>-arm64.dmg from the
latest release, open it, and
drag orzma.app onto the Applications folder next to it. This does not put
orzmd and orzbrowser on your PATH; to run them by name, add
/Applications/orzma.app/Contents/Resources to your PATH. To upgrade, download
the new dmg and replace orzma.app the same way.
First launch
orzma is not notarized by Apple, so macOS blocks its first launch, however you installed it, and again after each upgrade, with a warning that orzma could not be verified as free of malware. To open it, clear its quarantine flag:
xattr -dr com.apple.quarantine /Applications/orzma.app
Alternatively, after macOS blocks orzma, open System Settings > Privacy & Security (on macOS 11 and 12, System Preferences > Security & Privacy > General) and click Open Anyway next to the message about 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. To upgrade, run the installer of the new release; it replaces the
installed version.
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.
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.
If ~/.local/bin is not on your PATH, the installer says so; add it, or log
out and back in, to run orzma from a shell.
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.
Uninstall
Remove orzma the way you installed it:
- Homebrew: run
brew uninstall --cask orzma. - dmg: move
/Applications/orzma.appto the Trash. - Windows: open Settings > Apps (Installed apps on Windows 11),
select orzma, and choose Uninstall. This also removes orzma from
your
PATHand the Start menu. - .deb: run
sudo apt remove orzma. - Tarball: run
~/.local/share/orzma/uninstall.sh($XDG_DATA_HOME/orzma/uninstall.shwhenXDG_DATA_HOMEis set).
None of these removes your settings in ~/.config/orzma
(%USERPROFILE%\.config\orzma on Windows); delete that directory to remove
them as well.
Every install includes the companion apps orzmd and
orzbrowser. To build orzma from source, see Contributing.
Next, First Steps walks through the keys you use every day.
Upgrading
This page lists, release by release, the changes that need you to edit your configuration file and the changes in behavior you are likely to notice. When you skip releases, start at the section for the release after yours and read up the page to the newest. The releases page lists every change.
If orzma starts with the default settings after an upgrade, your configuration file still uses something the new release rejects; Validation explains how to see the reason.
0.3.0
The stock shortcuts are Alt chords
The pane and vi mode shortcuts moved from the leader to direct Alt chords,
and the new tab shortcuts use Alt as well
(Default Key Bindings lists them all):
| Action | 0.2 | 0.3 |
|---|---|---|
enter-vi-mode | <Leader>s | Alt+s |
select-left-pane, select-down-pane, select-up-pane, select-right-pane | <Leader>h, <Leader>j, <Leader>k, <Leader>l | Alt+h, Alt+j, Alt+k, Alt+l |
split-vertical-pane, split-horizontal-pane | <Leader>i, <Leader>o | Alt+i, Alt+o |
kill-pane | <Leader>p | Alt+p |
resize-left-pane, resize-down-pane, resize-up-pane, resize-right-pane | <Leader:r>Shift+H, <Leader:r>Shift+J, <Leader:r>Shift+K, <Leader:r>Shift+L | r:Alt+Shift+H, r:Alt+Shift+J, r:Alt+Shift+K, r:Alt+Shift+L |
- If your configuration binds one of the new
Altchords to another action, or uses one as a chordleader, orzma does not start. Rebind that action, or unbind the stock action that now holds the chord (see Conflicts). - The
Altchords orzma binds no longer reach the shell, such as readline’sAlt+candAlt+r(seeAltchords, the shell, and the Option key).
To keep the 0.2 keys, bind them back:
[shortcuts]
enter-vi-mode = "<Leader>s"
select-left-pane = "<Leader>h"
select-down-pane = "<Leader>j"
select-up-pane = "<Leader>k"
select-right-pane = "<Leader>l"
split-vertical-pane = "<Leader>i"
split-horizontal-pane = "<Leader>o"
kill-pane = "<Leader>p"
resize-left-pane = "r:<Leader>Shift+H"
resize-down-pane = "r:<Leader>Shift+J"
resize-up-pane = "r:<Leader>Shift+K"
resize-right-pane = "r:<Leader>Shift+L"
The right Option key is Alt on macOS
[keyboard] option_as_alt now defaults to "right" instead of "none": the
right Option key runs the Alt shortcuts and sends Meta to the shell, and no
longer types special characters. The left Option key still does. If your
configuration sets option_as_alt = "none", neither Option key runs the stock
shortcuts, so remove that line. A Japanese (JIS) keyboard has no right Option
key; set "left" or "both" there.
Repeatable bindings are marked with r:
<Leader:r>xis no longer accepted, and a configuration that uses it is ignored as a whole. Writer:<Leader>xinstead.- A direct chord now fires once per press, however long you hold it. Put
r:in front of a chord you bound yourself to make it keep firing while held (see Repeatable bindings). - After a leader tap, a key with no
<Leader>binding runs its direct chord instead of being swallowed.
Shortcuts run while a web page has focus
Direct chords other than copy and paste now run orzma’s shortcuts while a
web page has focus, and the page no longer receives them. To give them to the
page, set direct-chords-over-webview = false in [shortcuts] (see
Shortcuts while a webview has focus).
The wheel scrolls more slowly
The [mouse] defaults changed: lines_per_notch from 3 to 1,
cells_per_notch from 0.5 to 0.3333, and max_protocol_events_per_frame
from 8 to 24. One cell of wheel travel now scrolls about three lines instead
of six. To scroll the scrollback about as fast as before:
[mouse]
lines_per_notch = 3
cells_per_notch = 0.5
One other change cannot be undone by these settings: a program that tracks
the mouse now gets one wheel report per cell of travel, whatever
cells_per_notch says. At the new defaults, fine_lines equals
lines_per_notch, so holding fine_modifier changes nothing; the settings
above bring fine scrolling back, since lines_per_notch = 3 is again larger
than fine_lines.
Tabs
orzma now keeps several layouts of panes as tabs, listed in a tab bar at the top of the window (see Tabs). Closing the last pane of a tab closes the tab, and closing the last tab quits orzma. A tab you have not named shows the title of its active pane.
Companion apps
- orzmd: the page takes keyboard focus once it has loaded, so
Ctrl+cno longer quits once the page has loaded; quit withq. - orzbrowser:
Ctrl+cno longer quits in Normal mode or in the address bar; quit withq. Text with a space, or a single word without a dot, is searched for instead of opened as an address, andlocalhost, an IP address, orname:portopens overhttpinstead ofhttps.
Installing on macOS
The macOS download is a .dmg instead of a .zip (see
Installation).
0.2.0
tmux integration removed
orzma no longer drives tmux: panes are orzma’s own (see
Panes and Tabs). These [shortcuts] actions
were removed: detach-session, next-session, previous-session,
rename-session, new-window, kill-window, next-window,
previous-window, select-window-0 … select-window-9, rename-window, and
zoom-pane. The [scrollback] table was removed as well. A configuration
that still sets any of them is ignored as a whole, so delete those lines. The
tab actions of 0.3.0 replace the window actions.
Fonts are named, not loaded from files
Each face of [font] was a path to a font file; now it is a table that names
an installed font family and a style (see Font faces).
A path makes orzma ignore the whole file, and so does an unknown key in
[font].
[font.normal]
family = "JetBrains Mono"
style = "Regular"
New shortcuts
copy (Cmd+C) and the font size shortcuts (Cmd+Plus, Cmd+-, and
Cmd+0) were added. If your configuration binds one of these chords to
another action, orzma does not start; rebind one of the two.
Webview programs mount with an APC sequence
The OSC 5379 sequences that mounted and unmounted a registration were
replaced by the APC mount and unmount sequences, which name an instance
with n= (see mount). The focus and
navigate ops name a placement by its instance instead of a handle. The
SDKs do this for you.
First Steps
This walkthrough covers the keys you need every day, using the default key bindings. Default Key Bindings lists all of them.
The Alt key on macOS
On macOS, the right Option key acts as Alt for orzma’s shortcuts, and the
left Option key keeps typing special characters. If your Mac has no right
Option key, as with a Japanese (JIS) keyboard, set
option_as_alt in your
configuration file (see Change a setting):
[keyboard]
option_as_alt = "left"
On Windows and Linux, either Alt key works, except AltGr on a keyboard
layout that has it.
Split the window
- Press
Alt+ito split the pane side by side, orAlt+oto split it top and bottom. The new pane becomes the active pane. - Press
Alt+h,Alt+j,Alt+k, orAlt+lto move to the pane on the left, below, above, or on the right. You can also click a pane. - Hold
Alt+Shift+H,J,K, orLto move a divider, or drag it with the mouse. - Press
Alt+pto close the active pane.
Panes and Tabs has the details.
Open a tab
A tab is a separate layout of panes, shown at the top of the window.
- Press
Alt+cto open a new tab. - Press
Alt+1to go back to the first one, orAlt+]andAlt+[to step through them. You can also click a tab. - Press
Alt+rto rename the tab on screen.
Copy text in vi mode
- Press
Alt+sto enter vi mode. - Move with
h,j,k, andl, pressvto start a selection, and move again to extend it. - Press
yto copy the selection and leave vi mode.
Vi Mode lists every motion.
Read a Markdown file
Run orzmd with a Markdown file:
orzmd README.md
orzmd shows the rendered file in the pane and updates it when the file is
saved. Press j and k to scroll, and q to quit. If your shell cannot find
orzmd, see Companion Apps to add it to your PATH.
Change a setting
orzma reads its configuration file when it starts. Unless you set
ORZMA_CONFIG or XDG_CONFIG_HOME, the file is ~/.config/orzma/config.toml
(%USERPROFILE%\.config\orzma\config.toml on Windows). Create the file, add
a setting, and restart orzma:
[font]
size = 14
If the setting has no effect, check that the file is where File location says; if orzma reports a problem with it, Validation explains how to see the reason. Configuration lists every setting, and Key Bindings explains how to change the shortcuts.
Panes and Tabs
orzma can split its window into panes, each running its own shell, and keep several such layouts as tabs across the top of the window. The panes are part of orzma itself, so there is no separate multiplexer to start.
| Action | Default | What it does |
|---|---|---|
split-vertical-pane | Alt+i | Split the active pane side by side (vertical divider); the new pane becomes active. |
split-horizontal-pane | Alt+o | Split the active pane top and bottom (horizontal divider); the new pane becomes active. |
select-left-pane | Alt+h | Make the pane to the left active. |
select-down-pane | Alt+j | Make the pane below active. |
select-up-pane | Alt+k | Make the pane above active. |
select-right-pane | Alt+l | Make the pane to the right active. |
kill-pane | Alt+p | Close the active pane and end its shell. |
resize-left-pane | r:Alt+Shift+H | Move a divider of the active pane 5 cells left, repeatable (see Resizing panes). |
resize-down-pane | r:Alt+Shift+J | Move a divider of the active pane 5 cells down, repeatable (see Resizing panes). |
resize-up-pane | r:Alt+Shift+K | Move a divider of the active pane 5 cells up, repeatable (see Resizing panes). |
resize-right-pane | r:Alt+Shift+L | Move a divider of the active pane 5 cells right, repeatable (see Resizing panes). |
Alt is the right Option key on macOS by default, and either Alt key on
Windows and Linux (the left one on a keyboard with AltGr). See
Key Bindings to change these keys, and
Alt chords, the shell, and the Option key
for what they take away from the shell.
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 of a tab closes, the tab closes; when the last tab closes, orzma quits.
Tabs
A tab is one layout of panes. The tab bar across the top of the window lists every tab; the highlighted tab is the one on screen. The shells in the other tabs keep running, and pages shown in them keep their state.
| Action | Default | What it does |
|---|---|---|
new-tab | Alt+c | Open a new tab after the last one and show it. |
close-tab | Alt+Shift+X | Close the tab on screen and end every shell in it. |
next-tab | Alt+] | Show the tab to the right, wrapping around. |
previous-tab | Alt+[ | Show the tab to the left, wrapping around. |
select-tab-1 … select-tab-9 | Alt+1 … Alt+9 | Show the first … ninth tab. |
rename-tab | Alt+r | Rename the tab on screen. |
Click a tab to show it, click its × to close it, and click + to open a new
one. When the tabs do not fit in the window, turn the mouse wheel over the tab
bar to scroll through them. A tab you have not named shows the title of its
active pane, as set by the program running there, and Tab n when that pane
has no title, where n is the tab’s position in the tab bar. A new tab starts
in the working directory of the active pane, like a split.
Double-click a tab, or press the rename-tab key, to rename it in place.
Enter or a click anywhere else keeps the new name, and Escape keeps the old
one. A tab whose label you leave as it was stays unnamed and keeps following
its pane’s title. Leave the field empty to go back to the automatic label.
Drag a tab to move it; the numbers of unnamed tabs without a title follow their
new positions.
Resizing panes
The resize keys move one divider of the active pane 5 cells in the key’s direction. They are repeatable: hold the key 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. The resize keys work in vi mode too.
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 other than the active pane 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 Alt+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 Claude
Code run, there is no scrollback for vi mode to move through. When the program
tracks the mouse, vi mode lets it scroll its own content instead, except while
you drag a mouse selection: the scroll keys (Ctrl+Y, Ctrl+E, K,
J, Ctrl+B, Ctrl+F) and k on the top line or j on the bottom line
send the program wheel events at the vi cursor, and the wheel without Shift
reaches the program at the pointer. Any selection is cleared before the
program scrolls, and how far each event scrolls is up to the program. A held
key scrolls the program at most once every tenth of a second, so a program that
speeds up a fast-spinning wheel, as Claude Code does, still scrolls at a steady
pace. A program that does not track the mouse, such as less, is not scrolled:
there the wheel does nothing while vi mode is on.
Actions
Change these keys in the [vi-mode] table of the configuration file; see
Vi mode keys.
| 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 | K | Scroll half a page up. |
half-page-down | J | 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 keys from tmux’s vi copy mode (copy-mode-vi)
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 macOS dmg keeps them
inside orzma.app, and the Linux tarball keeps them in ~/.local/share/orzma;
see Installation to add them to your PATH.
To build them from a clone of the repository instead, run just install-apps.
- orzmd is a Markdown viewer with live reload, diagrams, math, and in-page search.
- orzbrowser is a keyboard-driven browser with link hints and an address bar that also searches.
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 with its own chrome: a rail at the top, an optional outline sidebar, and a find box. 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 sidebar — jump between the document’s headings; it opens on the section you are reading.
- In-page search — matches highlight as you type, and you step through them.
Usage
orzmd <markdown-file>
The rail at the top shows the file name and the heading you are reading, after its parent headings:
configuration.md › Settings › [inactive_pane]
If the file is deleted, the rail shows a red File deleted badge and the
last rendered content stays on screen. Messages such as
cannot open ../notes.md appear at the bottom of the page and disappear after
four seconds or at your next key press.
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 / ArrowDown | Scroll down one line |
k / ArrowUp | Scroll up one line |
J / K | 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 sidebar |
/ | Start a search |
n / N | Next / previous match (after a search) |
r | Reload the file |
Backspace / Ctrl+o | Go back to the previous document |
q | Quit |
The page handles every key and keeps keyboard focus while you read, so
Ctrl+c does not quit while the page has focus: on Windows and Linux it copies
the page’s selection, and on macOS you copy with Cmd+c. Quit with q. While
the page does not have focus — before it has loaded, or after you come back to
the pane with the keyboard — Ctrl+c still quits. Holding a scroll key keeps
the page scrolling smoothly until you release it, and each tap moves exactly
one step. While a search query is being typed, every key, q included, goes
into the find box.
Outline sidebar
| Key | Action |
|---|---|
j / ArrowDown | Move the selection down |
k / ArrowUp | Move the selection up |
Enter | Jump to the selected heading |
| Click a heading | Jump to that heading |
o / Tab / Escape | Close the sidebar |
/ | Start a search, keeping the sidebar open |
r / Backspace / Ctrl+o / q | Reload / go back / quit, as in reading |
The outline opens with the section you are reading selected.
Search
| Key | Action |
|---|---|
| (type) | Build the query; matches highlight as you type |
Enter | Keep the matches and return to reading; with no match, nothing happens |
Escape | Cancel the search and return to where it started |
After Enter, use n / N in reading mode to move between matches, and
Escape to clear the highlight. The search ignores case unless the query
contains an uppercase letter.
If the find box loses focus while you type — for example, you click another pane — orzmd keeps the matches when there are any and otherwise closes the find box; either way the page stays where it is.
orzbrowser
orzbrowser runs inside an orzma pane and loads a remote URL in an embedded webview below a two-row toolbar. You drive it from the keyboard like a Vim-style pager — scrolling, following links by typing hint labels, and stepping through history — and type addresses or search terms into the toolbar’s address bar.
Features
- Vim-style scrolling —
j/kmove by line,J/Kby half a page,Ctrl+f/Ctrl+bby a full page, andgg/Gjump to the top / bottom. Holding a key keeps scrolling smoothly until you let go. When a frame embedded in the page (a code demo, a video player) has keyboard focus, the keys scroll that frame, and scroll the page around it once the frame can scroll no further that way. - 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 with search —
o(or:, or a click on the address) opens the address bar. Type an address to open it, or any other words to search for them. The caret moves with the arrow keys, and input methods work, so you can search in any language. - Modal input — Normal, Insert, Address, Hint, and Help modes. In Normal
mode the page holds keyboard focus and the scroll keys scroll it;
ienters Insert mode, where every key butEscapetypes into the page, andEscapereturns to Normal. - In-app help —
?shows the full shortcut list.
Usage
orzbrowser [address or search terms]
orzbrowser github.com opens a site, and orzbrowser rust async searches for
the words. With no argument, orzbrowser opens the search engine’s front page
with the address bar ready for typing.
The toolbar shows the mode, the address with its host in bold, and the main
keys of the mode. An http address shows Not secure before its host.
Address bar
The address bar decides what Enter does as you type, and shows it on the right
(↵ Open github.com or ↵ Search DuckDuckGo):
| You type | Enter does |
|---|---|
An http:// or https:// URL | Opens it |
A URL with another scheme, such as file:///etc | Nothing; the reason turns red |
Text with a space, such as rust async | Searches for it |
localhost, an IP address, or name:port | Opens it over http |
A name with a dot and a letter-only ending, such as docs.rs/serde | Opens it over https |
Anything else, such as rust or 3.14 | Searches for it |
? followed by words, such as ? node.js | Searches for the words, even when they look like an address |
Pressing Enter on the current address reloads the page. Escape closes the
address bar without navigating. While the address bar is open, Ctrl+c does
not quit; press Escape and then q.
Searches go to DuckDuckGo. To use another engine, set ORZBROWSER_SEARCH_URL
to its search URL with {} where the search terms go:
export ORZBROWSER_SEARCH_URL='https://www.google.com/search?q={}'
The URL must be http or https and contain exactly one {}, in its path,
query, or fragment; otherwise orzbrowser exits with an error.
Keyboard shortcuts
Normal
| Key | Action |
|---|---|
j / ArrowDown | Scroll down one line |
k / ArrowUp | Scroll up one line |
J / Space | Scroll half a page down |
K | 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 (type into the page) |
f | Follow a link (show hints) |
? | Show help |
q | Quit |
In Normal mode the page holds keyboard focus, so Ctrl+c copies the page’s
selection instead of quitting (on macOS, copy with Cmd+c); quit with q.
Ctrl+c quits only while the pane is too short to show the page.
Address bar
| Key | Action |
|---|---|
| (type) | Edit the address; the arrow keys, Home, and End move the caret |
Enter | Open the address, or search for the words |
Escape | Cancel |
Hint
| Key | Action |
|---|---|
| (type label) | Narrow to and follow the matching hint |
Backspace | Delete the last label character |
Escape | Cancel hints |
Ctrl+c | Quit |
Insert
| Key | Action |
|---|---|
Escape | Return to Normal mode |
In Insert mode every other key goes to the page, so you can type into focused inputs. orzma’s own shortcuts still run first (see Key Bindings).
Help
| Key | Action |
|---|---|
Escape / 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;
the scrolling of orzbrowser and orzmd is ported from Vimium’s
content_scripts/scroller.js (commit e34b529328), and its license notice is
reproduced in
THIRD-PARTY-LICENSES.md; the rest of orzbrowser is an independent
implementation.
Configuration
orzma reads its configuration from a TOML file when it starts. If the file does not exist, every setting keeps its default. Restart orzma after you change the file.
File location
orzma resolves the config path in this order:
$ORZMA_CONFIG— used verbatim if set and not empty.$XDG_CONFIG_HOME/orzma/config.toml— if$XDG_CONFIG_HOMEis set and not empty.~/.config/orzma/config.toml— the default.
On macOS, orzma opened from the Dock or Finder does not see variables set in
your shell’s startup files. Set them with launchctl setenv, or keep the file
at ~/.config/orzma/config.toml.
~ 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 table, an unknown key in any table except
[mouse]and[inactive_pane], a value of the wrong type or an unknown word, a malformed key binding, a file that cannot be read, such as one that is not UTF-8, or a home directory that orzma cannot find. - 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, open Command Prompt (cmd.exe), run
orzma 2> orzma-error.txt, and open the file.
Unknown keys in [mouse] and [inactive_pane] are ignored. A few values are
clamped or replaced with their default instead of being rejected; the keys
below say which.
Settings
Every key is optional: write only the ones you want to change. For example:
[cursor]
style = "bar"
[font]
size = 13
The two key-binding tables, [shortcuts] and [vi-mode], are described in
Key Bindings.
[orzma]
shell
Default: none · A program path
The shell each new pane runs. Without shell, or with an empty one, orzma
uses $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. On macOS,
orzma starts the shell through zsh, so ~ and environment variables in the
value are expanded; on Windows and Linux the value is used as written.
[orzma]
shell = "/opt/homebrew/bin/fish"
shell_integration
Default: true · true or false · Windows only
Whether orzma makes PowerShell (pwsh, powershell) and cmd report their
working directory, so that a split pane starts in the same directory (see
Working directory of a new pane).
It has no effect on macOS and Linux.
[cursor]
style
Default: "block" · "block", "underline", or "bar"
The caret shape. Case does not matter. An unknown word keeps the default instead of making orzma ignore the whole file. Programs can change the shape while they run (see Cursor and text attributes).
blink_interval
Default: 750 · Milliseconds
How long the caret stays lit, and then dark, while it blinks. The caret blinks
from the start, and programs can turn blinking off and on (see Modes). 0 keeps the caret
steady whatever a program asks for. A value from 1 to 9 is raised to 10.
blink_timeout
Default: 5 · Seconds
How long the caret keeps blinking after your last keystroke; then it stays lit
until you type again. 0 keeps it blinking. A value shorter than one full
blink, twice blink_interval, is raised to that.
thickness
Default: 0.15 · A number from 0 to 1
The thickness of the underline and bar carets, and of the hollow caret’s outline, as a fraction of the cell width. A value outside 0 to 1 is clamped. The caret is always at least one physical pixel thick.
unfocused_hollow
Default: true · true or false
Whether the caret of an inactive pane, and of every pane while the orzma window is not focused, is drawn as a hollow block.
[selection]
semantic_escape_chars
Default: the characters below · A string
The characters that end a word for the vi mode w, b, and e motions.
Spaces and tabs always end a word as well.
[selection]
semantic_escape_chars = ",│`|:\"' ()[]{}<>\t"
[font]
size
Default: 11.25 · Logical pixels
The terminal font size. It must be greater than 0 and at most 200; otherwise
orzma does not start. The font size shortcuts change the size while orzma
runs, and reset-font-size returns to this value.
normal, bold, italic, bold_italic
Default: the bundled JetBrains Mono Nerd Font · Tables of family and style
The four faces the terminal draws text with. Each is a table with two optional keys:
familyis the name of an installed font family. A face withoutfamilyusesnormal.family. When no face sets a family, orzma draws the terminal with its bundled JetBrains Mono Nerd Font and ignores these four faces’style. A family that is not installed stops orzma from starting.styleis a weight,ItalicorOblique, or both, as in"Bold Italic","Medium", or"Italic". The weights areThin,ExtraLight,Light,Regular,Medium,SemiBold,Bold,ExtraBold, andBlack, and the common aliases such asNormal,Book,DemiBold, andHeavywork too. Case, spaces, and hyphens do not matter, and numeric weights such as700are not accepted. A face withoutstyleuses its own: Regular, Bold, Italic, or Bold Italic. orzma picks the installed face closest to the weight and slant, rather than matching the face’s name. An unknown style stops orzma from starting.
[font.normal]
family = "JetBrains Mono"
style = "Regular"
[font.bold]
style = "Bold" # uses normal's family
[font.italic]
family = "Cascadia Code"
style = "Italic"
ui
Default: normal’s family and style · A table of family and style
The face of orzma’s own interface: the tab bar and its rename
field, the input method’s preedit text, and the vi mode indicator. family
and style each default to those of normal and follow the same rules. With
the bundled font, orzma uses the bundled face closest to style.
[font]
ui = { family = "Inter", style = "Medium" }
[keyboard]
option_as_alt
Default: "right" · "none", "left", "right", or "both" · macOS only
Which Option key acts as Alt: it sends Meta to the program in the terminal
and runs Alt shortcuts. The other Option key types special characters, such
as © for Option+g; for shortcuts it still counts as Alt with a key that
types no character, such as an arrow, and together with Ctrl or Cmd. In vi
mode, an accent key pressed with the character-typing Option key, such as left
Option+e under the default, runs as its plain key.
A MacBook with a Japanese (JIS) keyboard has no right Option key; set
"left" or "both" there. On Windows and Linux every Alt key is Alt except
AltGr, which types characters, and this setting has no effect.
[mouse]
Unknown keys in this table are ignored.
lines_per_notch
Default: 1 · Lines
How many lines one notch of wheel travel scrolls through the scrollback, or how many arrow keys it sends on the alternate screen. Mouse reports to a program ignore it.
cells_per_notch
Default: 0.3333 · Cells
How far the wheel or trackpad has to travel, in cells, to count as one notch. The default makes one cell of wheel travel three notches, which scroll three lines. A smaller value scrolls faster. Mouse reports to a program ignore it: they send one report per whole cell of travel.
fine_modifier
Default: "alt" · "alt", "ctrl", "shift", or "none"
The modifier that makes the wheel scroll fine_lines per notch instead of
lines_per_notch. "none" makes fine scrolling always active, and "shift"
has no effect on macOS.
fine_lines
Default: 1 · Lines
How many lines one notch scrolls while fine_modifier is held. At the
defaults it equals lines_per_notch, so the modifier changes nothing until
you raise lines_per_notch.
max_protocol_events_per_frame
Default: 24 · A count
The most wheel events orzma sends to a program in one frame, on each axis: mouse reports, and notches turned into arrow keys on the alternate screen. Events past the limit are dropped.
axis_lock_ratio
Default: 0.9 · A number from 0 to 1
How strictly a trackpad swipe sticks to its main direction. The horizontal
part of a swipe is kept only when it makes up at least this share of the
motion; 0 turns the lock off, and 1 keeps horizontal motion only for a
purely horizontal swipe. A value outside 0 to 1 is clamped.
double_click_timeout_ms
Default: 400 · Milliseconds
The longest pause between the clicks of a double or triple click.
click_drift_px
Default: 8.0 · Logical pixels
How far the pointer may move between the clicks of a double or triple click.
divider_grab_tolerance_px
Default: 4.0 · Logical pixels
How far from a pane divider, on each side, a press still grabs it for resizing. It is never less than half a cell.
[inactive_pane]
These keys set how panes other than the active pane are drawn. Unknown keys in this table are ignored, and a number outside 0 to 1 is clamped.
enabled
Default: true · true or false
Whether inactive panes are dimmed and tinted at all.
dim
Default: 1.0 · A number from 0 to 1
The brightness of an inactive pane: 1.0 leaves it as it is, and lower
values darken it.
tint_color
Default: "#3a3b45" · "#RRGGBB"
The color an inactive pane’s background is blended toward. A value that is
not #RRGGBB keeps the default.
tint
Default: 0.85 · A number from 0 to 1
How far the background is blended toward tint_color: 0 keeps the real
background, and 1 replaces it. Text is not tinted.
webview_dim
Default: 0.55 · A number from 0 to 1
The brightness of the web pages in an inactive pane.
webview_desaturate
Default: 0.6 · A number from 0 to 1
How grey the web pages in an inactive pane turn: 0 keeps full color, and
1 is fully grey.
[shortcuts]
[shortcuts] binds orzma’s own shortcuts; Key Bindings
describes the action keys, and
Default Key Bindings lists their defaults. The
table also has these keys:
leader
Default: "Cmd" on macOS, "Alt" on Windows and Linux · A modifier or a chord
The key that starts a <Leader> binding: a modifier to tap, a chord to
press, or "" to turn the leader off (see
The leader key).
leader-tap-timeout-ms
Default: 300 · Milliseconds
How long a tap of the leader modifier may last before it no longer counts.
0 reverts to 300.
repeat-time-ms
Default: 500 · Milliseconds
How long a r:<Leader> binding waits for its key again (see
Repeatable bindings). 0 turns
repeating off for those bindings.
direct-chords-over-webview
Default: true · true or false
Whether direct chords other than copy and paste run orzma’s shortcuts
while a webview has focus. With false, the page gets them, except a direct
chord bound to release-webview-focus, which always runs (see
Shortcuts while a webview has focus).
[vi-mode]
[vi-mode] binds the keys that work inside vi mode;
Vi mode keys describes it.
Key Bindings
orzma reads key bindings from two tables of the configuration file:
[shortcuts]holds orzma’s own shortcuts. Every section below up to Example describes it.[vi-mode]holds the keys that work inside vi mode. Vi mode keys describes it.
The keys orzma binds out of the box are listed in Default Key Bindings.
Each [shortcuts] action takes one binding: a direct chord such as Alt+h, a
<Leader> binding such as <Leader>u, either of them with r: in front, or
"" to unbind it. An action you leave out keeps its default.
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: an ASCII letter
atozor a digit (letters are case-insensitive),[,],-,=, or a named key:EscapeSpaceEnterTabBackspaceArrowUpArrowDownArrowLeftArrowRightPlus. Named keys are case-sensitive, soescapeis not one. Any other single character, including a non-ASCII letter such asö, is accepted for an action but never fires, and orzma logs a warning; as a chordleader, it stops orzma from starting. - For the
+key itself, use the tokenPlus(e.g.Cmd+Plus). - Keys are matched by their position on a US keyboard: on another layout,
Alt+his the key whereHsits on a US keyboard, whatever its label.
Examples: Cmd+Shift+Q, Ctrl+Alt+ArrowLeft, Cmd+Plus.
Invalid chords — an empty token (Cmd+), an unknown or miscapitalized named
key (Cmd+F12, Cmd+escape), 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).
The leader key
The leader is a second way to bind an action: the leader followed by one
more key, written <Leader> in the configuration. By default only
release-webview-focus (<Leader>u) uses it; the other stock shortcuts are
direct chords such as Alt+h. 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.
After a tap, the next keystroke runs a <Leader> action when one matches.
Otherwise it is handled as if no leader had been tapped when it has a direct
chord, and is swallowed when it has none. The leader does not time out while
it waits. Switching to another window with the keyboard, such as Cmd+Tab or
Alt+Tab, cancels it; switching with the mouse does not.
leadersets the leader: a modifier to tap ("Cmd","Ctrl", or"Alt", or one of their aliases above), a chord such as"Ctrl+A"(press the chord, then the action’s key), or""to turn the leader off."Shift"is not allowed and makes orzma ignore the whole file. The leader does nothing while no action has a<Leader>binding.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 how long ar:<Leader>binding (below) waits for its key again, 500 ms by default;0turns repeating off for them. It does not affect direct chords markedr:.
Repeatable bindings (r:)
Put r: in front of a binding to make it repeatable. It works on both kinds of
binding:
- A direct chord such as
r:Alt+Shift+Hkeeps firing while you hold it, at your system’s key repeat rate. A direct chord withoutr:fires once per press, however long you hold it. - A leader binding such as
r:<Leader>Shift+Hre-fires without the leader: after it fires, pressing any repeat-marked key again withinrepeat-time-ms(default 500) runs its action again, and each fire starts that time over. Holding the key down keeps firing. Any other key — including keys bound with plain<Leader>— ends the repeat at once and is handled normally (it is never swallowed). Pressing the leader during the repeat starts a fresh leader sequence.
The stock resize bindings, increase-font-size, and decrease-font-size carry
r:; no other stock binding does.
Caveat: with a letter key (say r:<Leader>h), typing that same letter into the
shell before repeat-time-ms runs out re-fires the action instead of reaching
the terminal. If that bites, set repeat-time-ms = 0 or drop the r: from
that binding.
In vi mode a repeatable leader binding fires only on the key pressed right
after the leader: the repeat ends 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. A repeatable direct chord, such as the stock
r:Alt+Shift+H, runs before the [vi-mode] keys and keeps firing while held.
Alt chords, the shell, and the Option key
The stock pane, tab, and vi-mode shortcuts are Alt chords, so those
keys do not reach the program in the terminal as meta-prefixed keys:
readline’s Alt+c (capitalize word), Alt+r (revert line), and Alt+1 …
Alt+9 (numeric argument), for example. Unbind or rebind a shortcut to give
its key back to the shell. Alt chords that orzma does not bind, such as
Alt+b and Alt+f, still reach the shell.
- macOS: the Option key that
option_as_altmakes Alt runs theAltchords — the right Option key by default (seeoption_as_alt). The other Option key types special characters, so leftOption+istarts an accent instead of splitting the pane; it still counts asAltwith an arrow or another key that types no character, and together withCtrlorCmd. A MacBook with a Japanese (JIS) keyboard has no right Option key; setoption_as_alt = "left"or"both"there. - Windows and Linux: on a keyboard layout with AltGr, such as German, the right Alt key is AltGr and types characters; use the left Alt key for the shortcuts.
Conflicts and turning the leader off
Things to know when you rebind:
- Binding a chord that a stock default already uses to another action
(e.g.
split-vertical-pane = "Alt+h", which collides with the defaultselect-left-pane = "Alt+h") is a startup validation error naming both actions, and so is using such a chord as the chordleader; see Validation. The stock pane, tab, and vi-mode shortcuts are allAltchords (see Default Key Bindings). Unbind the stock default explicitly (select-left-pane = "",rename-tab = "") or pick a free chord. Binding a chord to the action that already has it by default is not a conflict. A direct chord and a<Leader>binding never collide, even on the same key, such assand<Leader>s. - Two spellings of one key are not caught at startup.
Cmd+PlusandCmd+=press the same key: orzma starts, logs a warning, and runs only one of the two actions. Bind each key once. leader = ""disables every<Leader>-bound action at once — with the stock defaults that is onlyrelease-webview-focus, silently (a warning is logged, but startup succeeds). If you disable the leader, rebind it to a direct chord, e.g.release-webview-focus = "Ctrl+Shift+U".
Shortcuts while a webview has focus
Once you click into a webview, keys go to the page, but orzma’s own shortcuts still come first:
<Leader>bindings andrelease-webview-focusalways run.- Other direct chords, such as
Cmd+Plusor the stockAltpane and tab chords, run whiledirect-chords-over-webviewistrue, the default. Set it tofalseto let the page have them;release-webview-focus(<Leader>u) still takes the keyboard back. copyandpastebound to direct chords never run while a webview has focus, soCmd+CandCmd+Vcopy and paste inside the page.
A chord that runs an orzma shortcut never reaches the page, and it runs even when the page’s program lists it as a forward key.
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. [ and ] name physical key positions of a US layout; on a JIS
keyboard they are the keys labelled @ and [.
Example
A [shortcuts] table that changes a few defaults and keeps the rest:
[shortcuts]
# Tap Ctrl to start a <Leader> binding.
leader = "Ctrl"
# Split with the leader, giving Alt+i and Alt+o back to the shell.
split-vertical-pane = "<Leader>i"
split-horizontal-pane = "<Leader>o"
# Give Alt+r back to the shell.
rename-tab = ""
# Keep resizing while Shift+H is held or pressed again within repeat-time-ms.
resize-left-pane = "r:<Leader>Shift+H"
Vi mode keys ([vi-mode])
The [vi-mode] actions and their default keys are listed under
Vi mode.
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 = ""). An array that holds an empty string, such as[""], makes orzma ignore the whole file.- 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.
Default Key Bindings
These are the keys orzma binds when your configuration leaves them alone. To change them, see Key Bindings. Windows and Linux share the same defaults, and only the general shortcuts differ on macOS.
General
| Action | macOS | Windows and Linux | What it does |
|---|---|---|---|
leader | Cmd (tap) | Alt (tap) | The key that starts a <Leader> binding (see The leader key). |
paste | Cmd+V | Ctrl+V | Paste from the system clipboard. |
copy | Cmd+C | Ctrl+C | Copy the active pane’s selection to the system clipboard, then dismiss the selection. |
increase-font-size | r:Cmd+Plus | r:Ctrl+Plus | Step the terminal font size up. |
decrease-font-size | r:Cmd+- | r:Ctrl+- | Step the terminal font size down. |
reset-font-size | Cmd+0 | Ctrl+0 | Return the terminal font size to [font] size. |
release-webview-focus | <Leader>u | <Leader>u | Return keyboard focus from a focused webview to the terminal. |
enter-vi-mode | Alt+s | Alt+s | Enter vi mode. |
quit | Cmd+Q | unbound | Quit orzma. |
r: marks a binding that keeps firing while you hold it (see
Repeatable bindings).
quit ships unbound on Windows and Linux 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.
On Windows and Linux, binding paste to Ctrl+V takes 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.
Panes
| Action | Default | What it does |
|---|---|---|
split-vertical-pane | Alt+i | Split the active pane side by side (vertical divider); the new pane becomes active. |
split-horizontal-pane | Alt+o | Split the active pane top and bottom (horizontal divider); the new pane becomes active. |
select-left-pane | Alt+h | Make the pane to the left active. |
select-down-pane | Alt+j | Make the pane below active. |
select-up-pane | Alt+k | Make the pane above active. |
select-right-pane | Alt+l | Make the pane to the right active. |
kill-pane | Alt+p | Close the active pane and end its shell. |
resize-left-pane | r:Alt+Shift+H | Move a divider of the active pane 5 cells left, repeatable (see Resizing panes). |
resize-down-pane | r:Alt+Shift+J | Move a divider of the active pane 5 cells down, repeatable (see Resizing panes). |
resize-up-pane | r:Alt+Shift+K | Move a divider of the active pane 5 cells up, repeatable (see Resizing panes). |
resize-right-pane | r:Alt+Shift+L | Move a divider of the active pane 5 cells right, repeatable (see Resizing panes). |
Tabs
| Action | Default | What it does |
|---|---|---|
new-tab | Alt+c | Open a new tab after the last one and show it. |
close-tab | Alt+Shift+X | Close the tab on screen and end every shell in it. |
next-tab | Alt+] | Show the tab to the right, wrapping around. |
previous-tab | Alt+[ | Show the tab to the left, wrapping around. |
select-tab-1 … select-tab-9 | Alt+1 … Alt+9 | Show the first … ninth tab. |
rename-tab | Alt+r | Rename the tab on screen. |
Vi mode
These keys work only in vi mode. They are set in the [vi-mode]
table (see Vi mode keys). Over a
full-screen program that tracks the mouse, the scroll actions, and
cursor-up / cursor-down at the edge of the screen, scroll the program
instead (see Vi mode).
| 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 | K | Scroll half a page up. |
half-page-down | J | 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). |
Terminal Compatibility
This page lists what a program running in orzma can rely on: the environment it starts in, the keys and mouse events it receives, and the escape sequences orzma understands.
Environment
TERMcomes from the environment orzma was started in, so panes of an orzma started from another terminal inherit that terminal’sTERM. WhenTERMis unset or empty, as when you open orzma from the Dock or a desktop menu, orzma setsTERM=xterm-256colorandCOLORTERM=truecolor. orzma has no terminfo entry of its own.- On macOS, when the locale (
LC_ALL,LC_CTYPE, orLANG) is not UTF-8, orzma setsLC_CTYPE=en_US.UTF-8. - orzma sets
FORCE_HYPERLINK=1unless the environment it was started in already sets it. Programs that read it, such as Claude Code, then print links as OSC 8 hyperlinks, so a link the program wraps onto several rows itself opens in full from any of them. To turn this off, setFORCE_HYPERLINK=0in your shell profile. - Every pane gets
ORZMA_SOCKandORZMA_TOKEN, which webview apps use to reach orzma (see Discovery). - orzma does not set
TERM_PROGRAM. Panes of an orzma started from another terminal inherit that terminal’sTERM_PROGRAMand similar variables, so programs may treat orzma as that terminal.
Keyboard
The bullets below describe the keys while no program has asked for the kitty keyboard protocol. In that form the keypad sends what the main keyboard’s keys of the same name send, and the application keypad mode (DECKPAM) does not change it.
-
With a key that types a character,
Alt, including the Option key thatoption_as_altmakesAlton macOS, sendsESCbefore the character. -
Ctrlwith a letter sends that letter’s control character.Ctrlalso turns these keys into control characters:Key with CtrlSends Space,@,2NUL [,3ESC \,4FS ],5GS ^,~,6RS _,/,7US ?,8DEL Ctrlwith any other key, such as1or-, sends the plain character.Ctrl+AltsendsESCbefore the control character. -
AltandCtrldo not changeBackspace,Enter,Escape, orTab;Shift+TabsendsCSI Z. -
Without a modifier, the arrow keys,
Home, andEndfollow the application cursor keys mode (DECCKM). -
With
Shift,Alt, orCtrlheld, the arrow keys,Home, andEndsend xterm’s modified forms whatever DECCKM says, such asCSI 1 ; 5 DforCtrl+ArrowLeft.Insert,Delete,PageUp, andPageDowndo the same, such asCSI 5 ; 5 ~forCtrl+PageUp. -
F1toF4sendSS3 PtoSS3 S, andF5toF12sendCSI 15 ~toCSI 24 ~. WithShift,Alt, orCtrlheld, they send xterm’s modified forms, such asCSI 1 ; 2 PforShift+F1andCSI 15 ; 5 ~forCtrl+F5.F13and above send nothing.
A program that asks for the kitty keyboard protocol’s “disambiguate escape
codes” receives keys in that protocol’s form instead, so Shift+Enter,
Ctrl+C, and Escape reach it as keys of their own.
Mouse
- Mouse tracking: modes 1000 (clicks), 1002 (drags), and 1003 (all motion), reported in the X10 encoding or the SGR encoding (1006). The X10 encoding reports a column or row past 223 as 223. The UTF-8 (1005) and urxvt (1015) encodings are not supported.
- Hold
Shiftto keep the mouse from a program that tracks it, so you can select text; the wheel then acts as it does for a program that does not track the mouse. - On the alternate screen, the wheel sends arrow keys to a program that does not track the mouse (alternate scroll, mode 1007, on by default).
- Focus reporting (mode 1004) sends
CSI IandCSI Owhen the pane becomes or stops being the active pane, and when the orzma window gains or loses focus. A web page in the pane taking the keyboard sends no report.
OSC sequences
| OSC | What it does | Notes |
|---|---|---|
| 0, 2 | Set the window title | The window shows the active pane’s title, and an unnamed tab shows its active pane’s title. CSI 22 t and CSI 23 t save and restore the title. orzma does not report the title back, and ignores OSC 1 (icon name). |
| 4, 104 | Set, query, and reset palette colors | Colors 0 to 255, written as rgb: or # values. |
| 7 | Report the working directory | A file:// URL. New panes use it (see Working directory of a new pane). |
| 8 | Hyperlinks | The id= parameter is supported. |
| 9;9 | Report the working directory on Windows | A Windows path. Other OSC 9 forms are ignored. |
| 10, 11, 12 | Set and query the default foreground, background, and cursor colors | |
| 110, 111, 112 | Reset those three colors | |
| 52 | Copy to the clipboard | Writing only: a program cannot read the clipboard. Data that is not valid base64 clears the clipboard. |
Modes
| Mode | What it does |
|---|---|
| 1 (DECCKM) | Application cursor keys |
| 6 (DECOM) | Origin mode |
| 7 (DECAWM) | Auto-wrap, on by default |
| 12 | Cursor blinking |
| 25 (DECTCEM) | Show the cursor, on by default |
| 47, 1047, 1049 | Alternate screen; 1049 also saves the cursor |
| 1000, 1002, 1003, 1006 | Mouse tracking and the SGR encoding (see Mouse) |
| 1004 | Focus reporting |
| 1007 | Alternate scroll, on by default |
| 2004 | Bracketed paste |
| 2026 | Synchronized output; a frame is held for at most 150 ms |
4 (IRM, set with CSI 4 h) | Insert mode |
Cursor and text attributes
CSI Ps SP q(DECSCUSR) sets the caret shape: 1 and 2 a block, 3 and 4 an underline, 5 and 6 a bar, blinking for odd values and steady for even ones. 0 returns to the[cursor]shape and turns blinking back on.- OSC 12 sets the caret color.
- Text can be bold, dim, italic, underlined, reversed, hidden, and struck through, in 16 colors, 256 colors, or 24-bit color. Every underline style is drawn as a single underline. Underline colors, blinking text, and overlines are not drawn.
Not supported
- Images: sixel, the kitty graphics protocol, and iTerm2 inline images.
- Keyboard protocols: xterm’s modifyOtherKeys.
- The kitty keyboard protocol’s other enhancements: reporting event types, alternate keys, all keys as escape codes, and associated text.
- The XTVERSION, DECRQSS, and XTGETTCAP queries.
- Left and right margins (DECLRMM) and reverse video (DECSCNM).
Glossary
Terminal
Active pane
The pane that receives your keystrokes. A new pane and a pane you click become the active pane, and the other panes are tinted (see Inactive panes).
Alternate screen
A second screen that full-screen programs such as vim and less draw on. It has no scrollback, and the primary screen comes back unchanged when the program exits.
Chord
A key pressed together with zero or more modifiers, such as Alt+h or
Cmd+Plus. A direct chord runs an action by itself, and a leader binding
is the leader followed by one more key (see
Chord syntax).
Leader
A key that starts a two-key shortcut, written <Leader> in the configuration.
By default it is a tap of Cmd on macOS and of Alt on Windows and Linux
(see The leader key).
Pane
A part of the window that runs its own shell. The panes of a tab share the window below the tab bar (see Panes and Tabs).
Primary screen
The screen a shell prints to. Lines that scroll off its top go into the scrollback.
Scrollback
The lines that have scrolled off the top of the primary screen. orzma keeps the latest 10,000 lines of each pane. Scroll back with the mouse wheel or in vi mode.
Tab
One layout of panes, shown in the tab bar at the top of the window and labelled with its name or its active pane’s title. The shells in the tabs that are not on screen keep running (see Tabs).
Vi mode
A mode that moves a cursor over the screen and the scrollback with vi keys, to select and copy text (see Vi Mode).
Webview apps
These terms appear in Building Webview Apps and the Webview Protocol.
Bridge
The window.orzma object orzma adds to a page, through which the page calls
its program and exchanges events with it (see
The window.orzma bridge).
Control socket
The local socket a program talks to orzma over. orzma gives each pane the
socket’s path in ORZMA_SOCK and a token in ORZMA_TOKEN (see
The control socket).
Forward keys
Key chords that a program asks orzma to send to its pane instead of to its focused page, so that the program keeps keys such as its own quit key (see Forward keys).
Handle
The opaque ID orzma returns when a program registers content. It names the registration.
Host
orzma’s side of the protocol: it keeps each program’s registrations and draws their pages.
Instance
The ID of one placement. A program mounts, unmounts, focuses, and navigates placements by their instance IDs (see Instance semantics).
Mount
To show a placement in a rectangle of terminal cells, with the APC mount
sequence or the socket mount op. Unmounting removes it again (see
APC webview verbs).
Page
The web page a placement shows: the registered HTML, file, or URL, loaded in its own webview.
Placement
One place where a registration’s page appears in a pane. A registration can have several placements, each with its own instance ID and its own page.
Program
The terminal app that runs in a pane, registers content, and mounts it. The
Rust SDK ratatui_orzma implements the
program’s side of the protocol.
Registration
Content a program has registered with orzma: inline HTML, a directory of files, or a URL. It is named by its handle.
Webview
The embedded browser that draws a page inside the terminal.
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. ratatui_orzma needs Rust 1.88 or later. The examples
also use let chains, so code you copy from them needs the 2024 edition as
well.
ratatui_orzma and @orzma/web share orzma’s version number. Use the release
that matches the orzma your app runs in; see
Versions.
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+f` focuses the webview (bare keys then type into its input); `Alt+b`
//! returns focus to the app; `q` quits while the app is focused.
//!
//! Only `Alt+b` is declared as a forward-key, and that asymmetry is the point:
//! `Alt+b` 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+f` 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('b'),
}]))?;
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+f focus webview · Alt+b 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('f')) => {
view.focus()?;
}
(KeyModifiers::ALT, KeyCode::Char('b')) => {
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+b 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. The orzma shortcuts that run while a page has focus take precedence over both (see Key Bindings). 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. The Glossary defines the terms this page uses.
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.
Example exchange
A short session, from the handshake to a second instance. Program-to-host
lines are marked C→S, host-to-program lines S→C; the sections below
describe each message.
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"}
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 placements. |
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 registration 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 registration).
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 a mounted placement accepts pointer and keyboard input.forward_keys— the initial forward keys; replace them later withset_forward_keys.bridge(urlonly) — opt into thewindow.orzmaback-channel.dirandinlineare always bridged; aurlregistration 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 page is bridged. It is honored for every kind, including aurlregistration without the bridge.
Focus
Outside vi mode, a pointer press inside a mounted interactive placement gives it keyboard focus and makes its pane the active pane, and a press on the terminal outside every placement gives the keyboard back to the terminal. While a placement holds focus, the host delivers keys to its page, except for orzma’s own shortcuts, which run first:
<Leader>bindings andrelease-webview-focusalways run.- Other direct chords, such as
Cmd+Plusor the stockAltpane and tab chords, run whiledirect-chords-over-webviewistrue, the default. Set it tofalseto let the page have them;release-webview-focus(<Leader>u) still takes the keyboard back. copyandpastebound to direct chords never run while a webview has focus, soCmd+CandCmd+Vcopy and paste inside the page.
After those, the placement’s forward keys go to the pane’s PTY instead of the page.
A focus op moves focus to a mounted, interactive placement this connection
owns and makes its pane the active pane, or, with null, takes it back from
whichever placement 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.
A focus of a placement whose pane is in a tab that is not on screen is
ignored, and the page that has focus keeps it. compositing is reported for
placements in hidden tabs as well.
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. A chord that also runs one of orzma’s
shortcuts runs the shortcut instead (see Focus). register carries
the initial list;
set_forward_keys replaces it wholesale. Each chord is:
{ "mods": ["alt"], "key": "b" }
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. A character typed through AltGr, or on macOS with the Option key thatoption_as_altleaves for typing characters, counts as unmodified, so it matches a chord with no modifiers. Characters typed through a dead key do not match.
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 bridged placement 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 aurlregistration 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.
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 registrations are always bridged; a url registration is bridged only 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 at once, even when the page’s CSS sets scroll-behavior: smooth. 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 placements.- Closing the control connection purges all of that program’s handles, removes
their placements, 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 placements, 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 placement) andfocus_changedfalse(if the placement held focus). - For a bridged placement, 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.
Versions
orzma, ratatui_orzma, and @orzma/web are released together under one
version number. The protocol has no version handshake, so use the SDK release
that matches the orzma your app runs in. A host that does not know an op
skips the line without replying (see Request ordering),
and ratatui_orzma then fails a request that expects a reply with
OrzmaError::Disconnected after five seconds.
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
This page shows the repository’s CONTRIBUTING.md without its release steps. To change it, edit that file.
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.97) 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, Ninja, and Python 3.12 or later:
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 Installation 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.
On Windows, just build, just run and just test also place the ConPTY
orzma ships in target\debug, fetched once from nuget.org, and a plain
cargo run or cargo test uses it only after one of them has run.
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.
- 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 is published.
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.