Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. orzmd shows Markdown and orzbrowser browses 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

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 .deb is 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.app to the Trash.
  • Windows: open Settings > Apps (Installed apps on Windows 11), select orzma, and choose Uninstall. This also removes orzma from your PATH and the Start menu.
  • .deb: run sudo apt remove orzma.
  • Tarball: run ~/.local/share/orzma/uninstall.sh ($XDG_DATA_HOME/orzma/uninstall.sh when XDG_DATA_HOME is 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):

Action0.20.3
enter-vi-mode<Leader>sAlt+s
select-left-pane, select-down-pane, select-up-pane, select-right-pane<Leader>h, <Leader>j, <Leader>k, <Leader>lAlt+h, Alt+j, Alt+k, Alt+l
split-vertical-pane, split-horizontal-pane<Leader>i, <Leader>oAlt+i, Alt+o
kill-pane<Leader>pAlt+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+Lr:Alt+Shift+H, r:Alt+Shift+J, r:Alt+Shift+K, r:Alt+Shift+L
  • If your configuration binds one of the new Alt chords to another action, or uses one as a chord leader, orzma does not start. Rebind that action, or unbind the stock action that now holds the chord (see Conflicts).
  • The Alt chords orzma binds no longer reach the shell, such as readline’s Alt+c and Alt+r (see Alt chords, 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>x is no longer accepted, and a configuration that uses it is ignored as a whole. Write r:<Leader>x instead.
  • 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+c no longer quits once the page has loaded; quit with q.
  • orzbrowser: Ctrl+c no longer quits in Normal mode or in the address bar; quit with q. Text with a space, or a single word without a dot, is searched for instead of opened as an address, and localhost, an IP address, or name:port opens over http instead of https.

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

  1. Press Alt+i to split the pane side by side, or Alt+o to split it top and bottom. The new pane becomes the active pane.
  2. Press Alt+h, Alt+j, Alt+k, or Alt+l to move to the pane on the left, below, above, or on the right. You can also click a pane.
  3. Hold Alt+Shift+H, J, K, or L to move a divider, or drag it with the mouse.
  4. Press Alt+p to 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.

  1. Press Alt+c to open a new tab.
  2. Press Alt+1 to go back to the first one, or Alt+] and Alt+[ to step through them. You can also click a tab.
  3. Press Alt+r to rename the tab on screen.

Copy text in vi mode

  1. Press Alt+s to enter vi mode.
  2. Move with h, j, k, and l, press v to start a selection, and move again to extend it.
  3. Press y to 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.

ActionDefaultWhat it does
split-vertical-paneAlt+iSplit the active pane side by side (vertical divider); the new pane becomes active.
split-horizontal-paneAlt+oSplit the active pane top and bottom (horizontal divider); the new pane becomes active.
select-left-paneAlt+hMake the pane to the left active.
select-down-paneAlt+jMake the pane below active.
select-up-paneAlt+kMake the pane above active.
select-right-paneAlt+lMake the pane to the right active.
kill-paneAlt+pClose the active pane and end its shell.
resize-left-paner:Alt+Shift+HMove a divider of the active pane 5 cells left, repeatable (see Resizing panes).
resize-down-paner:Alt+Shift+JMove a divider of the active pane 5 cells down, repeatable (see Resizing panes).
resize-up-paner:Alt+Shift+KMove a divider of the active pane 5 cells up, repeatable (see Resizing panes).
resize-right-paner:Alt+Shift+LMove 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.

ActionDefaultWhat it does
new-tabAlt+cOpen a new tab after the last one and show it.
close-tabAlt+Shift+XClose the tab on screen and end every shell in it.
next-tabAlt+]Show the tab to the right, wrapping around.
previous-tabAlt+[Show the tab to the left, wrapping around.
select-tab-1 … select-tab-9Alt+1 … Alt+9Show the first … ninth tab.
rename-tabAlt+rRename 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 (pwsh and powershell) and cmd report it automatically; set shell_integration = false in 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.

ActionDefaultWhat it does
cursor-lefth, ArrowLeftMove the cursor one cell left.
cursor-downj, ArrowDownMove the cursor one cell down.
cursor-upk, ArrowUpMove the cursor one cell up.
cursor-rightl, ArrowRightMove the cursor one cell right.
line-start0Jump 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-wordwJump to the next (semantic) word start.
previous-wordbJump to the previous (semantic) word start.
next-word-endeJump to the next (semantic) word end.
next-spaceWJump to the next space-delimited word start.
previous-spaceBJump to the previous space-delimited word start.
next-space-endEJump to the next space-delimited word end.
screen-topHJump to the top visible line.
screen-middleMJump to the middle visible line.
screen-bottomLJump 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-topgScroll to the oldest history line.
history-bottomGScroll to the live tail.
page-upCtrl+BScroll one page up.
page-downCtrl+FScroll one page down.
half-page-upKScroll half a page up.
half-page-downJScroll half a page down.
scroll-upCtrl+YScroll one line up.
scroll-downCtrl+EScroll one line down.
toggle-selectionv, SpaceToggle a character-wise selection.
toggle-line-selectionVToggle a line-wise selection.
toggle-rect-selectionCtrl+VToggle a rectangular selection. Currently selects whole lines.
yanky, EnterCopy the selection to the clipboard and leave vi mode.
exitq, Escape, Ctrl+CLeave vi mode.
search-forward/Open the search-down prompt (not implemented yet).
search-backward?Open the search-up prompt (not implemented yet).
search-nextnRepeat the previous search (not implemented yet).
search-previousNRepeat the previous search, reversed (not implemented yet).
jump-forwardfOpen the jump-to-char-forward prompt (not implemented yet).
jump-backwardFOpen the jump-to-char-backward prompt (not implemented yet).
jump-to-forwardtOpen the jump-till-char-forward prompt (not implemented yet).
jump-to-backwardTOpen 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 — ```mermaid fences 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

KeyAction
j / ArrowDownScroll down one line
k / ArrowUpScroll up one line
J / KScroll half a page down / up
Ctrl+f / Ctrl+bScroll a full page down / up
Space / PageDownScroll a full page down
PageUpScroll a full page up
ggJump to the top
GJump to the bottom
]] / [[Jump to the next / previous heading
o / TabToggle the outline sidebar
/Start a search
n / NNext / previous match (after a search)
rReload the file
Backspace / Ctrl+oGo back to the previous document
qQuit

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

KeyAction
j / ArrowDownMove the selection down
k / ArrowUpMove the selection up
EnterJump to the selected heading
Click a headingJump to that heading
o / Tab / EscapeClose the sidebar
/Start a search, keeping the sidebar open
r / Backspace / Ctrl+o / qReload / go back / quit, as in reading

The outline opens with the section you are reading selected.

KeyAction
(type)Build the query; matches highlight as you type
EnterKeep the matches and return to reading; with no match, nothing happens
EscapeCancel 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 / k move by line, J / K by half a page, Ctrl+f / Ctrl+b by a full page, and gg / G jump 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 f to 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 — H and L step 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; i enters Insert mode, where every key but Escape types into the page, and Escape returns 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 typeEnter does
An http:// or https:// URLOpens it
A URL with another scheme, such as file:///etcNothing; the reason turns red
Text with a space, such as rust asyncSearches for it
localhost, an IP address, or name:portOpens it over http
A name with a dot and a letter-only ending, such as docs.rs/serdeOpens it over https
Anything else, such as rust or 3.14Searches for it
? followed by words, such as ? node.jsSearches 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

KeyAction
j / ArrowDownScroll down one line
k / ArrowUpScroll up one line
J / SpaceScroll half a page down
KScroll half a page up
Ctrl+f / PageDownScroll a full page down
Ctrl+b / PageUpScroll a full page up
ggJump to the top
GJump to the bottom
HHistory back
LHistory forward
o / :Open the address bar
rReload the page
iInsert mode (type into the page)
fFollow a link (show hints)
?Show help
qQuit

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

KeyAction
(type)Edit the address; the arrow keys, Home, and End move the caret
EnterOpen the address, or search for the words
EscapeCancel

Hint

KeyAction
(type label)Narrow to and follow the matching hint
BackspaceDelete the last label character
EscapeCancel hints
Ctrl+cQuit

Insert

KeyAction
EscapeReturn 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

KeyAction
Escape / qClose help
Ctrl+cQuit

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:

  1. $ORZMA_CONFIG — used verbatim if set and not empty.
  2. $XDG_CONFIG_HOME/orzma/config.toml — if $XDG_CONFIG_HOME is set and not empty.
  3. ~/.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).

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.

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:

  • family is the name of an installed font family. A face without family uses normal.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.
  • style is a weight, Italic or Oblique, or both, as in "Bold Italic", "Medium", or "Italic". The weights are Thin, ExtraLight, Light, Regular, Medium, SemiBold, Bold, ExtraBold, and Black, and the common aliases such as Normal, Book, DemiBold, and Heavy work too. Case, spaces, and hyphens do not matter, and numeric weights such as 700 are not accepted. A face without style uses 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 (also Command / Meta / Super), Ctrl, Shift, Alt (also Opt / Option).
  • Keys: an ASCII letter a to z or a digit (letters are case-insensitive), [, ], -, =, or a named key: Escape Space Enter Tab Backspace ArrowUp ArrowDown ArrowLeft ArrowRight Plus. Named keys are case-sensitive, so escape is 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 chord leader, it stops orzma from starting.
  • For the + key itself, use the token Plus (e.g. Cmd+Plus).
  • Keys are matched by their position on a US keyboard: on another layout, Alt+h is the key where H sits 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.

  • leader sets 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-ms is how long a tap may last before it no longer counts, 300 ms by default; 0 reverts to 300.
  • repeat-time-ms is how long a r:<Leader> binding (below) waits for its key again, 500 ms by default; 0 turns repeating off for them. It does not affect direct chords marked r:.

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+H keeps firing while you hold it, at your system’s key repeat rate. A direct chord without r: fires once per press, however long you hold it.
  • A leader binding such as r:<Leader>Shift+H re-fires without the leader: after it fires, pressing any repeat-marked key again within repeat-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_alt makes Alt runs the Alt chords — the right Option key by default (see option_as_alt). The other Option key types special characters, so left Option+i starts an accent instead of splitting the pane; it still counts as Alt with an arrow or another key that types no character, and together with Ctrl or Cmd. A MacBook with a Japanese (JIS) keyboard has no right Option key; set option_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 default select-left-pane = "Alt+h") is a startup validation error naming both actions, and so is using such a chord as the chord leader; see Validation. The stock pane, tab, and vi-mode shortcuts are all Alt chords (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 as s and <Leader>s.
  • Two spellings of one key are not caught at startup. Cmd+Plus and Cmd+= 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 only release-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 and release-webview-focus always run.
  • Other direct chords, such as Cmd+Plus or the stock Alt pane and tab chords, run while direct-chords-over-webview is true, the default. Set it to false to let the page have them; release-webview-focus (<Leader>u) still takes the keyboard back.
  • copy and paste bound to direct chords never run while a webview has focus, so Cmd+C and Cmd+V copy 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 keys Escape Enter Space Tab Backspace ArrowUp ArrowDown ArrowLeft ArrowRight.
  • 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 paste chord does nothing in vi mode, so it passes through — the stock Ctrl+V on Windows and Linux reaches toggle-rect-selection. A <Leader>-scoped paste binding still pastes.
  • copy passes a Ctrl-only chord through whenever there is no selection to copy — the stock Ctrl+C on Windows and Linux reaches exit. 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

ActionmacOSWindows and LinuxWhat it does
leaderCmd (tap)Alt (tap)The key that starts a <Leader> binding (see The leader key).
pasteCmd+VCtrl+VPaste from the system clipboard.
copyCmd+CCtrl+CCopy the active pane’s selection to the system clipboard, then dismiss the selection.
increase-font-sizer:Cmd+Plusr:Ctrl+PlusStep the terminal font size up.
decrease-font-sizer:Cmd+-r:Ctrl+-Step the terminal font size down.
reset-font-sizeCmd+0Ctrl+0Return the terminal font size to [font] size.
release-webview-focus<Leader>u<Leader>uReturn keyboard focus from a focused webview to the terminal.
enter-vi-modeAlt+sAlt+sEnter vi mode.
quitCmd+QunboundQuit 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

ActionDefaultWhat it does
split-vertical-paneAlt+iSplit the active pane side by side (vertical divider); the new pane becomes active.
split-horizontal-paneAlt+oSplit the active pane top and bottom (horizontal divider); the new pane becomes active.
select-left-paneAlt+hMake the pane to the left active.
select-down-paneAlt+jMake the pane below active.
select-up-paneAlt+kMake the pane above active.
select-right-paneAlt+lMake the pane to the right active.
kill-paneAlt+pClose the active pane and end its shell.
resize-left-paner:Alt+Shift+HMove a divider of the active pane 5 cells left, repeatable (see Resizing panes).
resize-down-paner:Alt+Shift+JMove a divider of the active pane 5 cells down, repeatable (see Resizing panes).
resize-up-paner:Alt+Shift+KMove a divider of the active pane 5 cells up, repeatable (see Resizing panes).
resize-right-paner:Alt+Shift+LMove a divider of the active pane 5 cells right, repeatable (see Resizing panes).

Tabs

ActionDefaultWhat it does
new-tabAlt+cOpen a new tab after the last one and show it.
close-tabAlt+Shift+XClose the tab on screen and end every shell in it.
next-tabAlt+]Show the tab to the right, wrapping around.
previous-tabAlt+[Show the tab to the left, wrapping around.
select-tab-1 … select-tab-9Alt+1 … Alt+9Show the first … ninth tab.
rename-tabAlt+rRename 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).

ActionDefaultWhat it does
cursor-lefth, ArrowLeftMove the cursor one cell left.
cursor-downj, ArrowDownMove the cursor one cell down.
cursor-upk, ArrowUpMove the cursor one cell up.
cursor-rightl, ArrowRightMove the cursor one cell right.
line-start0Jump 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-wordwJump to the next (semantic) word start.
previous-wordbJump to the previous (semantic) word start.
next-word-endeJump to the next (semantic) word end.
next-spaceWJump to the next space-delimited word start.
previous-spaceBJump to the previous space-delimited word start.
next-space-endEJump to the next space-delimited word end.
screen-topHJump to the top visible line.
screen-middleMJump to the middle visible line.
screen-bottomLJump 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-topgScroll to the oldest history line.
history-bottomGScroll to the live tail.
page-upCtrl+BScroll one page up.
page-downCtrl+FScroll one page down.
half-page-upKScroll half a page up.
half-page-downJScroll half a page down.
scroll-upCtrl+YScroll one line up.
scroll-downCtrl+EScroll one line down.
toggle-selectionv, SpaceToggle a character-wise selection.
toggle-line-selectionVToggle a line-wise selection.
toggle-rect-selectionCtrl+VToggle a rectangular selection. Currently selects whole lines.
yanky, EnterCopy the selection to the clipboard and leave vi mode.
exitq, Escape, Ctrl+CLeave vi mode.
search-forward/Open the search-down prompt (not implemented yet).
search-backward?Open the search-up prompt (not implemented yet).
search-nextnRepeat the previous search (not implemented yet).
search-previousNRepeat the previous search, reversed (not implemented yet).
jump-forwardfOpen the jump-to-char-forward prompt (not implemented yet).
jump-backwardFOpen the jump-to-char-backward prompt (not implemented yet).
jump-to-forwardtOpen the jump-till-char-forward prompt (not implemented yet).
jump-to-backwardTOpen 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

  • TERM comes from the environment orzma was started in, so panes of an orzma started from another terminal inherit that terminal’s TERM. When TERM is unset or empty, as when you open orzma from the Dock or a desktop menu, orzma sets TERM=xterm-256color and COLORTERM=truecolor. orzma has no terminfo entry of its own.
  • On macOS, when the locale (LC_ALL, LC_CTYPE, or LANG) is not UTF-8, orzma sets LC_CTYPE=en_US.UTF-8.
  • orzma sets FORCE_HYPERLINK=1 unless 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, set FORCE_HYPERLINK=0 in your shell profile.
  • Every pane gets ORZMA_SOCK and ORZMA_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’s TERM_PROGRAM and 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 that option_as_alt makes Alt on macOS, sends ESC before the character.

  • Ctrl with a letter sends that letter’s control character. Ctrl also turns these keys into control characters:

    Key with CtrlSends
    Space, @, 2NUL
    [, 3ESC
    \, 4FS
    ], 5GS
    ^, ~, 6RS
    _, /, 7US
    ?, 8DEL

    Ctrl with any other key, such as 1 or -, sends the plain character. Ctrl+Alt sends ESC before the control character.

  • Alt and Ctrl do not change Backspace, Enter, Escape, or Tab; Shift+Tab sends CSI Z.

  • Without a modifier, the arrow keys, Home, and End follow the application cursor keys mode (DECCKM).

  • With Shift, Alt, or Ctrl held, the arrow keys, Home, and End send xterm’s modified forms whatever DECCKM says, such as CSI 1 ; 5 D for Ctrl+ArrowLeft. Insert, Delete, PageUp, and PageDown do the same, such as CSI 5 ; 5 ~ for Ctrl+PageUp.

  • F1 to F4 send SS3 P to SS3 S, and F5 to F12 send CSI 15 ~ to CSI 24 ~. With Shift, Alt, or Ctrl held, they send xterm’s modified forms, such as CSI 1 ; 2 P for Shift+F1 and CSI 15 ; 5 ~ for Ctrl+F5. F13 and 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 Shift to 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 I and CSI O when 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

OSCWhat it doesNotes
0, 2Set the window titleThe 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, 104Set, query, and reset palette colorsColors 0 to 255, written as rgb: or # values.
7Report the working directoryA file:// URL. New panes use it (see Working directory of a new pane).
8HyperlinksThe id= parameter is supported.
9;9Report the working directory on WindowsA Windows path. Other OSC 9 forms are ignored.
10, 11, 12Set and query the default foreground, background, and cursor colors
110, 111, 112Reset those three colors
52Copy to the clipboardWriting only: a program cannot read the clipboard. Data that is not valid base64 clears the clipboard.

Modes

ModeWhat it does
1 (DECCKM)Application cursor keys
6 (DECOM)Origin mode
7 (DECAWM)Auto-wrap, on by default
12Cursor blinking
25 (DECTCEM)Show the cursor, on by default
47, 1047, 1049Alternate screen; 1049 also saves the cursor
1000, 1002, 1003, 1006Mouse tracking and the SGR encoding (see Mouse)
1004Focus reporting
1007Alternate scroll, on by default
2004Bracketed paste
2026Synchronized 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::connect opens 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. The fallback widget 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, where window.orzma.on(name, handler) receives it.
  • The page sends an event with window.orzma.emit(name, payload). Declare the event with Webview::add_event::<T>(name) when you register the page, and read the events that arrived with view.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) answers window.orzma.call(method, params). The page’s params value 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 an Error carrying 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> or Arc<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, and orzma.blur() gives it back to the terminal.
  • Webview::forward_keys lists 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 with view.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-focus shortcut (<Leader>u by 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}` });
  });
}
FunctionWhat 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:

  1. The control socket — a local Unix-socket connection over which a program registers content, manages it, and routes the page back-channel.
  2. APC verbs — terminal escape sequences that mount and unmount registered content at a cell rectangle.
  3. The window.orzma bridge — 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 currently orzma: 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 op field. Both register and new_instance are answered this way, and either can also reply {"ok":false,"error":"…"}.
  • Every host-initiated push (call, event, compositing, focus_changed) carries an op field.

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:

opFieldsMeaning
hellotokenHandshake; first line only.
registerkind + per-kind fieldsRegister content; mints a handle and its first instance.
new_instancehandleMint an additional instance on a handle this connection owns.
unregisterhandleRelease a handle owned by this connection; removes its mounted placements.
replyreqId, ok, value?, error?Answer a host call (use the call’s reqId).
emithandle, event, payloadPush an event to every page mounted from the handle (delivered to window.orzma.on). Ignored for a url registration without the bridge.
focusinstance (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.
navigateinstance, actionNavigate one mounted placement in place.
mountinstance, row, col, rows, colsMount one placement at a 0-based cell of the pane’s active screen, the socket form of the APC mount (see below).
unmountinstanceRemove one placement, whether the socket mount or the APC mount placed it.
set_forward_keyshandle, 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:

kindRequiredOptional (default)Served at
dirroot (absolute dir path), entry (safe relative path, e.g. index.html)interactive (true), forward_keys ([]), preload ([])orzma://<handle>/<entry>
inlinehtml (full document, ≤ 4 MiB)interactive (true), forward_keys ([]), preload ([])orzma://<handle>/index.html
urlurl (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 with set_forward_keys.
  • bridge (url only) — opt into the window.orzma back-channel. dir and inline are always bridged; a url registration is bridged only with bridge: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 a url registration 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 and release-webview-focus always run.
  • Other direct chords, such as Cmd+Plus or the stock Alt pane and tab chords, run while direct-chords-over-webview is true, the default. Set it to false to let the page have them; release-webview-focus (<Leader>u) still takes the keyboard back.
  • copy and paste bound to direct chords never run while a webview has focus, so Cmd+C and Cmd+V copy 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 digit 0–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. backtab names the same key as tab, so include "shift" in mods to 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 that option_as_alt leaves 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:

opFieldsMeaning / response
callhandle, instance, reqId, method, paramsA page window.orzma.call(method, params). Respond with a reply carrying the same reqId.
eventhandle, event, payloadA page window.orzma.emit(event, payload). Fire-and-forget; no response.
compositinghandle, instance, active (bool)The placement first appeared on screen (true), or was unmounted after that (false). Only a bridged placement gets this push.
focus_changedhandle, 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:

  • emit vs. event. A page’s window.orzma.emit(name, …) arrives at the program as op:"event". A program’s own emit message (op:"emit") is delivered to pages’ window.orzma.on(name, …). Same idea (“named event”), two op values depending on direction.
  • urlChanged. For a url registration with bridge:true, the host reports top-level address changes as an op:"call" with method:"urlChanged" and params:{"url":"<new>"}. Despite the call shape it is fire-and-forget — any reply is 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>"}:

errorCause
invalid_rootdir.root is not an absolute path to an existing directory.
unsafe_entrydir.entry is empty or absolute, or contains a .. component or a leading . component.
html_too_largeinline.html exceeds 4 MiB.
invalid_urlurl.url does not parse or has no host.
unsupported_schemeurl.url is not http/https.
unknown_handlenew_instance.handle names no live registration.
not_ownernew_instance.handle is registered, but by another connection.
owner_goneThe register request’s pane has already closed.
internalThe 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 from register or new_instance; exactly 32 lowercase hex digits.
  • rows — decimal 1–200. cols — decimal 1–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 registered root. 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 at index.html; any subresource request returns 404. Use dir for multi-file content.
  • url — the remote http(s) page is loaded directly and has no orzma:// 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

MethodReturnsMeaning
call(method, params?)PromiseInvoke a program method; resolves with the program’s reply value, rejects with Error(error).
on(event, handler)voidSubscribe to a program emit.
off(event, handler)voidRemove a handler by reference.
emit(event, payload?)voidSend 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 call with owner_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 compositing false and focus_changed false for the placements that had them. Afterwards a register fails with owner_gone, and requests naming the released handles or instances are ignored or fail with unknown_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 compositing false (for a bridged placement) and focus_changed false (if the placement held focus).
  • For a bridged placement, the compositing push 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 the handle and the instance.

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 APC mount takes effect only in the pane that registered the instance’s handle, and an APC unmount acts 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 a reply by its originating connection: a program replaying another connection’s reqId can 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-side window.orzma bridge.

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 packageManager in package.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
CommandWhat it does
just buildBuild the workspace.
just runRun orzma.
just testRun every Rust test.
pnpm -r testRun the TypeScript tests.
pnpm check-typesType-check the TypeScript packages.
just fix-lintApply clippy fixes, rustfmt, and biome fixes.
just install-appsBuild 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.

CrateRole
orzma (src/)The app: window, input, UI, and the plugins below.
orzma_vtTerminal emulation: the screen, scrollback, escape sequences, and selection.
orzma_ttyRuns the shell under a PTY and drives orzma_vt.
orzmuxThe multiplexer backend: a thread that owns every pane and the pane layout.
bevy_orzmuxConnects the backend to the Bevy app.
bevy_orzma_tty_rendererDraws the terminal grid on the GPU.
orzma_webview_hostThe webview server: the control socket and every program’s registrations, placements, and focus.
bevy_orzma_webviewDraws webviews with CEF, serves their content through the orzma:// scheme, and relays the window.orzma bridge.
orzma_configsLoads 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-lint before sending a change. CI runs cargo fmt --check, cargo clippy with warnings as errors, the tests, cargo doc, cargo-deny, the third-party license check, and pnpm lint:ci.
  • Commit messages follow the Conventional Commits style of the history, for example feat(webview): …, fix(input): …, or docs: ….

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/src in the same pull request.
  • Draw diagrams with Mermaid in a code block tagged mermaid. In a sequenceDiagram, 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 of docs/book.
  • To upgrade mdBook or mdbook-mermaid, change the versions in the justfile. After upgrading mdbook-mermaid, delete docs/book/mermaid.min.js and docs/book/mermaid-init.js and run mdbook-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.md and in sdk/ratatui_orzma/README.md.