Finding the perfect terminal emulator is a pain.
A cross-platform, multi-pane terminal emulator with nested splits, resizing, grouping with broadcast input, and session persistence — built on Alacritty's VT/PTY backend with an original GPU-rendered frontend.
Terminator is great but is Linux/GTK-only. iTerm2 is great but is Mac only. Alacritty is fast and cross-platform but excludes tabs and splits. This project is inspired by the greats and attempts to combine them into a single native application for Windows, macOS, and Linux.
See CHANGELOG.md for a detailed history, and releases for binaries.
Add the APT repository, for apt upgrade support:
curl -fsSL https://w-p.github.io/pain/pain-archive-keyring.asc | sudo gpg --dearmor -o /etc/apt/keyrings/pain.gpg
echo "deb [signed-by=/etc/apt/keyrings/pain.gpg] https://w-p.github.io/pain ./" | sudo tee /etc/apt/sources.list.d/pain.list
sudo apt update && sudo apt install painmacOS builds ship as a universal pain.app (Intel and Apple Silicon in one
download) — drag it to Applications and open it like any other app.
The app isn't code-signed, so Gatekeeper blocks the first launch. Clear the
quarantine flag once — note -r, since the flag is set on files inside the
bundle too:
xattr -dr com.apple.quarantine /Applications/pain.apppain.app is a bundle, which on disk is a directory, not a single
executable file. Running ./pain.app from a shell fails with "permission
denied" (zsh) or "Is a directory" (bash) — that's the shell refusing to
execute a directory, not a problem with the download. To launch it from a
terminal:
open pain.app # hand it to macOS, same as double-clicking
./pain.app/Contents/MacOS/pain # run the binary directly, to see log outputAdd the DNF repository, for dnf upgrade support:
sudo dnf config-manager --add-repo https://w-p.github.io/pain/rpm/pain.repo
sudo dnf install painDownload the AppImage — one file, no install, works on any distribution with glibc 2.35 or newer (including immutable ones like Silverblue, Kinoite, and Bazzite):
curl -fLO https://w-p.github.io/pain/appimage/pain-x86_64.AppImage
chmod +x pain-x86_64.AppImage
./pain-x86_64.AppImageIf it exits complaining about FUSE, your distribution doesn't ship libfuse2 — Fedora and Ubuntu 24.04 among them. Either install it, or run without it:
./pain-x86_64.AppImage --appimage-extract-and-runDownload the archive from releases.
Run pain. There are no positional arguments — every pane starts your
configured shell.
| Option | Meaning |
|---|---|
-h, --help |
Usage summary, including this machine's config file path |
-V, --version |
Print the version |
-v, --verbose[=LIST] |
Diagnostic logging on stderr |
--era=NAME |
Use a retro era for this session (--era=list to see them) |
--settings |
Open the settings window at startup |
LIST is a comma-separated set of general, mouse, pty, foreground, or
all. The bare flag enables general alone — the others fire constantly
enough to drown it out, so each is an explicit opt-in.
On Windows, pain is a windowed application, so starting it doesn't open a
console and a shell you launch it from returns immediately. The options above
still print to the terminal you ran them from, and redirecting to a file works
normally — but because the shell no longer waits, their output appears just
after your next prompt. That's how every windowed Windows program with a
command line behaves.
Full documentation is in the man page: man pain.
Every shortcut is a default and can be changed — see Configuration. Keys not listed pass through to the shell.
Shortcuts are written the same way you write them in the config file: segments separated by spaces.
| Shortcut | Action |
|---|---|
ctrl shift o |
Split pane horizontally |
ctrl shift e |
Split pane vertically |
ctrl shift w |
Close pane (closing the last one exits) |
ctrl shift x |
Zoom pane to fill the window, or restore it |
ctrl shift q |
Quit, saving the session |
alt up, alt down, alt left, alt right |
Move focus to the neighbouring pane |
ctrl shift up, ctrl shift down, ctrl shift left, ctrl shift right |
Resize the focused pane |
ctrl + / ctrl - |
Font size up / down one point |
ctrl 0 |
Font size back to the default |
A font size set this way is saved, so it survives a restart.
"Ctrl and plus" is one chord to you and several to the OS, so all of them are
bound: ctrl = and ctrl shift + are the same physical key on a US layout,
and a numeric keypad sends ctrl + with no Shift. Likewise ctrl - and
ctrl shift -.
Clipboard shortcuts differ per platform, because what a terminal can safely claim differs per platform:
| Platform | Shortcut | Action |
|---|---|---|
| Windows, Linux | ctrl c |
Copy the selection if there is one, otherwise interrupt the running program |
| Windows, Linux | ctrl v |
Paste |
| macOS | cmd c / cmd v |
Copy / paste |
| macOS | cmd q / cmd w |
Quit / close pane |
ctrl c costs you nothing: with no selection it interrupts exactly as it
always has, and copying clears the selection so a second press interrupts
rather than copying again. Ctrl+V does displace readline's quoted-insert;
set "ctrl v" = "none" to get it back. On macOS the Ctrl key is left alone
entirely, since Command is where the clipboard belongs there.
Ctrl+Shift+C/Ctrl+Shift+V, the usual Linux-terminal clipboard chords,
are not bound. They only ever existed because the unshifted pair wasn't
available, which is no longer the case on either platform. If you have the
muscle memory, bind them back:
[keybindings]
"ctrl shift c" = "copy"
"ctrl shift v" = "paste"Pasted text is wrapped in bracketed-paste markers when the running program supports them, so your shell holds it at the prompt for review instead of running each line as it arrives. When the program doesn't support them, a multi-line paste asks for confirmation and shows exactly what will be sent.
Broadcast — sending your keystrokes to several panes at once — has no
default chord and is set from the title-bar menu. The broadcast_off,
broadcast_group, and broadcast_all actions can be bound if you want them.
Assigning a pane to a group is menu-only: it needs a group name, which a
chord can't carry.
| Input | Action |
|---|---|
| Double-click / triple-click | Select word / line |
Ctrl+click |
Open the URL under the pointer (holding Ctrl underlines it first) |
Shift+click |
Force local selection, bypassing an app's own mouse reporting |
| Right-click a title bar | Pane menu: split, arrange, group, broadcast, swap shell, settings |
| Right-click a terminal | Terminal menu: copy, paste, close |
| Scroll wheel | Scroll that pane — see below |
Shift+scroll |
Always scroll this terminal's history, whatever is running |
| Middle-click | Passed to programs that want mouse input |
Settings opens in its own window, so it can be moved anywhere — including onto another monitor — rather than being confined to the terminal window.
Shift+click is the standard escape hatch for selecting text inside
full-screen programs like vim or htop, which would otherwise eat the click.
Ctrl+click follows a link that a program marked as one (the OSC 8 escape
sequence, which tools like cargo and gcc emit), and otherwise falls back
to matching URLs in the visible text. Only http, https, ftp, ssh and
mailto are opened, whichever way the link was found — a terminal prints
paths and arbitrary output constantly, and handing any of it to the operating
system's default handler on a click is a wider door than this opens.
Scrolling goes to whichever is right for what's running, in this order: a
program that asked for mouse input (Claude Code, htop, lazygit, vim
with set mouse=a) receives the scroll itself; a full-screen program that
didn't ask gets arrow keys, which is how less and man scroll; otherwise
it moves through this terminal's own history. Hold Shift to force the
last of those. Horizontal scrolling is passed on the same way.
Panes you aren't looking at show a dot in their title bar when something has happened, so a build or a log tail off to the side doesn't need watching:
| Dot | Meaning | Cleared by |
|---|---|---|
| Blue | The shell produced output since you last focused this pane | Focusing the pane |
| Red | A program rang the terminal bell | Typing into the pane |
The two clear differently on purpose. Output is only interesting for a pane you aren't watching, so looking at it is enough. A bell is a program asking for attention, and it usually rings the instant a command starts — while its own pane is still focused, because you just pressed Enter there. So a bell survives focus and clears when you next type into that pane, which is what actually shows you noticed.
Settings live in a TOML file, read at startup and re-read when it changes:
| Platform | Path |
|---|---|
| Linux | ~/.config/pain/config.toml (honours $XDG_CONFIG_HOME) |
| macOS | ~/Library/Application Support/pain/config.toml |
| Windows | %APPDATA%\pain\config.toml |
pain --help prints the resolved path for the machine you're on. The file
doesn't exist until you save settings from the settings window or create it
yourself, and every key is optional — a partial file is valid, and anything
missing uses its default.
A malformed file is never fatal. A parse failure at startup falls back to
defaults with a message on stderr; a bad edit while running keeps the settings
already loaded rather than resetting them; and individual bad [keybindings]
lines are skipped one at a time rather than poisoning the whole table.
[general]
default_shell = "" # empty = platform default ($SHELL, or your Windows default)
scrollback_lines = 5000 # lines of history per pane (max 1000000)
confirm_multiline_paste = true
[appearance]
theme = "Graphite" # any built-in theme; see Themes below
font_family = "monospace" # any installed monospaced family
font_size = 13 # logical size, scaled by the display's DPI factor (6-48)
ligatures = false # shape != and => as single glyphs (needs a ligature font)
transparency = 100 # percent: 0 transparent .. 100 opaque
background_color = "" # empty = follow the theme; a hex value overrides it
accent_color = "#7fa2d6" # cursor, selection, interactive highlights
[cursor]
style = "block" # block | underline | beam
[keybindings]
"ctrl shift t" = "split_vertical"
"ctrl v" = "none" # hand a chord back to the shellconfirm_multiline_paste is the last check on an unreviewed paste running
arbitrary commands the instant it arrives; turning it off removes that.
font_size is a logical size scaled by your display's DPI factor, so 13
matches other applications on a scaled display rather than rendering smaller
than everything else.
Numeric settings are clamped to their valid range on load — font_size to
6–48, transparency to 0–100, scrollback_lines to at most 1000000 — with
a note on stderr saying what was changed. A value outside the range is used at
the nearest end rather than reset to its default, since "as big as you'll give
me" is a legible intent; a value that isn't a number at all falls back to the
default.
Colors that carry meaning rather than style — the broadcast-target border and
the pane activity dot, for instance — are fixed and unaffected by
accent_color. An unparseable color falls back to its default rather than
failing to load.
theme picks from over 600 built-in color schemes, compiled into the binary —
there is nothing to download or install. The theme supplies the 16 ANSI colors
programs draw with, plus the default foreground and background. Names match
the familiar ones: Dracula, Tokyo Night, Gruvbox Dark, Catppuccin Mocha, Solarized Light, and so on. Matching is case-insensitive, and a name
that isn't recognised falls back to the default rather than failing to load.
Pick one from Settings, where the list is filterable, or set it by hand.
background_color is an override: leave it empty and the background follows
whichever theme you choose, which is usually what you want since a theme's
background is part of its design. Set it to a hex value to pin the background
regardless of theme.
All but one theme come from the
iTerm2-Color-Schemes
collection (MIT). Graphite is this app's own default. See
assets/themes/README.md for the details and how
to regenerate the table.
Emoji render in color, using whichever color emoji font is installed — Noto Color Emoji on Linux, Apple Color Emoji on macOS, Segoe UI Emoji on Windows. Without one, they fall back to whatever monochrome glyph the system can find.
Symbols that programs print as ordinary text — ✓, ✗, ★, ➜ — stay
monochrome and one cell wide on purpose. They have emoji forms in Unicode,
but they're used constantly in build and test output, where turning them into
colored pictures would be worse and would break their alignment.
Off by default. An era is a period look — a specific machine, not a vague retro mood — bundling a palette, screen effects, a wire speed, and a font preference:
[retro]
era = "bbs"| Era | The machine |
|---|---|
green |
IBM 5151, 1981 — P1 green phosphor |
amber |
Amber CRT, mid-1980s — P3 phosphor |
cga |
IBM 5153, 1981 — the CGA 16 |
bbs |
Dial-up BBS, ~1993 — the CGA palette ANSI art was drawn for |
c64 |
Commodore 64, 1982 — VIC-II blues |
Pick one in Settings under Retro, or try one for a single session without touching your config:
pain --era=amber
pain --era=list # what's on offerAn era overrides your theme while it's active but never writes to it, so
turning it off restores whatever you had. An explicitly set
background_color, scanlines, or vignette still wins over the era —
someone who chose a value meant it.
You can also change era live, from a shell or a script:
printf '\e]7331;era=green\a'That's session-only and never saved, and the payload is an era name rather than colours, so program output can't produce an unreadable screen.
Individual settings, each following the era unless you set it:
[retro]
era = "green"
scanlines = 55 # 0-100
vignette = 45 # 0-100
hum = 30 # 0-100 — the drifting bar; see belowAll three have sliders in Settings, each showing the era's value until you
move it. A ↺ beside them hands control back to the era.
The vignette does two things at once, because doing only one of them is invisible: it darkens toward the corners and lifts the centre with a faint glow in the theme's foreground colour. Every CRT palette has a near-black background, and darkening near-black by a quarter moves it three levels out of 255 — so without something to darken there is nothing to see.
The hum bar is the soft band that drifted slowly up an old monitor when its power supply and the mains supply were slightly out of step — the ripple beating against the vertical refresh. It takes about nine seconds to cross the screen.
It's the only effect that moves, and so the only one that keeps the terminal
drawing while it sits idle. That cost is bounded: it stops entirely when the
window loses focus, and it redraws at a small fraction of your display's rate,
since a bar that slow doesn't need sixty frames a second. Set hum = 0 to get
the idle cost back completely.
Effects cover the terminal grid only. Pane title bars are chrome and are left alone.
The effects are deliberately static: no animation, no time input. That means they cost one extra draw on a frame that was already being rendered, so an idle terminal still renders nothing and still sleeps. Animated effects (phosphor decay, flicker) would force continuous redraw and aren't here.
Eras name period typefaces rather than bundling them, and use one if you have it installed. Nothing ships with pain, so this is a recommendation rather than a promise — the font makes more difference to a period look than the palette does, so it's worth installing one:
| Era | Font | Where |
|---|---|---|
green, amber, matrix |
VT323 | Google Fonts (OFL) |
cga, bbs |
Px437 IBM VGA 8x16 | int10h.org (CC BY-SA) |
c64 |
C64 Pro Mono | style64.org |
Without one, an era still applies its palette and effects and keeps whatever font you've configured.
Off by default. When enabled, each row's text is shaped in runs so a font can
render != as ≠, => as ⇒, and so on. This needs a font that actually
provides them — Fira Code, JetBrains Mono, Cascadia Code, Iosevka. With any
other font it has no visible effect.
It's opt-in for two reasons. Glyph positions come from the font's own advances
rather than from the cell grid, so a font whose ligature widths don't match
its cell width will drift out of alignment; and shaping is real per-frame work
that the default per-character path doesn't do. A ligature is never applied
across a color change or across the cursor, so editing != still shows you
which character you're on.
A chord is space-separated, case-insensitive, with modifiers in any order and
exactly one non-modifier segment: a single character, up/down/left/
right, or space. Write ctrl (or control) and cmd (or
logo/super/win).
Spaces rather than + so that + and - are writable as themselves —
"ctrl +". The older +-separated form ("ctrl+shift+e") is still read, so
existing config files keep working.
The action none unbinds a chord with no replacement. Recognized actions:
split_horizontal, split_vertical, close_pane, quit, focus_up,
focus_down, focus_left, focus_right, resize_up, resize_down,
resize_left, resize_right, toggle_zoom, copy, copy_or_interrupt,
paste, broadcast_off, broadcast_group, broadcast_all,
font_size_increase, font_size_decrease, font_size_reset.
Overrides are applied on top of a fresh copy of the defaults each time the file is read, so deleting a line restores that chord's built-in binding rather than leaving it stuck at the old override.
| Layer | Crate | Role |
|---|---|---|
| PTY | portable-pty |
Unix PTY + Windows ConPTY behind one API |
| VT backend | alacritty_terminal |
Parser, screen grid, scrollback, cursor state |
| Windowing / input | winit |
Cross-platform window creation + input events |
| Rendering | wgpu |
GPU rendering for the text grid and the UI chrome |
| Font shaping | cosmic-text |
Font discovery, shaping, Unicode width handling |
| UI chrome | egui |
Config panel, menus, non-grid UI |
vendor/wgpu-hal-29.0.4/ is a local-only patched copy of wgpu-hal, pulled
in automatically via [patch.crates-io] in the workspace Cargo.toml — see
vendor/README.md for what it fixes and why.
Standard Cargo workspace:
cargo build --release
cargo test --workspace
cargo run -p painThe .deb, .rpm, AppImage, and tarball are all produced from one compile
inside a container, which pins the glibc floor at 2.35 rather than letting
it drift upward with whatever the CI runner happens to be running. The same
script CI uses runs locally, with either podman or docker:
./scripts/linux-packages.sh build # artifacts into ./dist
./scripts/linux-packages.sh verify # install and start each one
./scripts/linux-packages.sh all # bothverify installs each package into a stock image of the distribution it
targets and starts the application under a virtual display with software
rendering. That last part matters: --version returns before the event loop
starts, so it never reaches the X11, Wayland, xkbcommon, or Vulkan
libraries — which are loaded at runtime and are exactly the dependencies
most likely to be declared wrong.
The .deb, .rpm, AppImage, and tarball are all built from one compile
inside a container, which pins the glibc floor at 2.35 rather than letting
it drift upward with whatever the CI runner happens to be running. The
same script CI uses runs locally, with either podman or docker:
./scripts/linux-packages.sh build # artifacts into ./dist
./scripts/linux-packages.sh verify # install and start each one
./scripts/linux-packages.sh all # bothverify installs each package into a stock image of the distribution it
targets and starts the application under a virtual display with software
rendering. That matters because --version returns before the event loop
starts, so it never touches the X11, Wayland, xkbcommon, or Vulkan
libraries — which are loaded at runtime and are exactly the dependencies
most likely to be declared wrong.
MIT — see LICENSE.