# hottyterm

**A terminal that speaks [hotty](https://hotty.neuroplast.io/docs/) natively.**

A fork of Ghostty with [hotty-blitz](https://hotty.neuroplast.io/docs/hotty-blitz/)
built in. A program that prints hotty **surfaces** gets HTML and CSS laid out and
drawn into cells — no script, no network, no kitty graphics across the pty.
Everything else is Ghostty.


[Releases](https://github.com/neuroplastio/hottyterm/releases) ·
[Install](#install) ·
[Settings](#settings) ·
[Build](#build-and-run) ·
[Branding](#branding) ·
[Performance](https://github.com/neuroplastio/hottyterm/blob/main/docs/performance.md) ·
[hotty-blitz](https://hotty.neuroplast.io/docs/hotty-blitz/)


hottyterm is not affiliated with Ghostty. "Ghostty" and its icon are trademarks
of Ghostty's non-profit; hottyterm uses neither. Its icon is the neuroplastio
mark.

## Why hottyterm

hotty is a protocol for putting HTML and CSS **surfaces** into terminal cells,
in-band, over a pty and SSH. It needs a host to prove it against. hottyterm is
that host in the shape of a real terminal: it shows what native support looks
like, keeps the protocol honest against a terminal people already run, and gives
programs a place to try surfaces without a polyfill.

It carries hotty and nothing else, keeps its changes to upstream files down to
hooks, and shares Ghostty's config file, `TERM` and resources, so anyone using
it can go back to Ghostty with nothing to migrate.

## Research, on the bleeding edge

hottyterm is a **research vehicle for hotty**. It exists to push the protocol,
not to settle it: it is where new hotty ideas land first, in a terminal people
actually use.

If the stars align and Ghostty gains official hotty support, hottyterm does not
stop — it stays on the bleeding edge. It keeps moving ahead of the official
implementation, and it may add experimental features that play well with the
neuroplastio software stack (plx, hotty-sdk, the hotty tools) but are too new,
too specialised or too tightly coupled to upstream yet.

That never becomes a lock-in. The base is deliberately boring: the config, the
`TERM` and the resources stay Ghostty's, and the experimental parts are additive
and namespaced, so going back to Ghostty means doing without them — never
undoing them.

## What the fork is

A mod of Ghostty (gov P-7): upstream Ghostty at the commit in
[`ghostty-ref`](https://github.com/neuroplastio/hottyterm/blob/main/ghostty-ref), plus the patches in
[`mods/ghostty/`](https://github.com/neuroplastio/hottyterm/tree/main/mods/ghostty). There is no copy of Ghostty's tree here, and
no GitHub fork. Each patch has a page beside it saying what it changes, why we
carry it and whether Ghostty might want it; neuroplast.io/modding lists them.

| | |
| --- | --- |
| upstream | `ghostty-org/ghostty` at `827d5c82` (2026-10-13, "1.3.2-dev") |
| patches | 37, in [`mods/ghostty/`](https://github.com/neuroplastio/hottyterm/tree/main/mods/ghostty), each with its `.md` |
| new code | `src/termio/hotty.zig`, 2744 lines: all of the hotty logic |
| upstream files touched | 23 files, 939 lines added and 85 removed: hooks, settings, the GTK touch gestures, the tab removal, and the generic patches (kitty region uploads, the resize race, toasts) |
| branding | generated on top from [`brand/`](https://github.com/neuroplastio/hottyterm/tree/main/brand) (below), never kept as a patch |

The hooks:

| file | hook |
| --- | --- |
| `src/termio/Termio.zig` | settings from the config; `osc_unknown_max_bytes`; flush once per read; re-render on resize; the IO thread's loop, for the timer that plays animated images and the wakeup for what surfaces fetch |
| `src/termio/stream_handler.zig` | the `osc_unknown` action (upstream #14452) goes to hotty.zig; full reset; a changed config |
| `src/config/Config.zig` | the `hotty-net` setting |
| `src/Surface.zig` | keys, clicks, hover and the wheel go to the surfaces first; the font size, for the CSS px; `mouseReport` and `mouseShiftCapture` public, for a finger in the program's areas |
| `src/build/GhosttyExe.zig` | links `libhotty_blitz` (`HOTTY_BLITZ_LIB`) |
| `src/apprt/gtk/class/surface.zig` | a touch drag scrolls, where the finger touched; a touch over a surface, or in an area of the program's cells, goes to hotty-blitz, taps included |

## How it works

- **Parsing is upstream's.** Ghostty passes every OSC it does not implement to
  the stream handler once `osc_unknown_max_bytes` is set; hotty.zig takes
  OSC 7279 and hands the body to hotty-blitz.
- **The protocol is hotty-blitz's,** at the commit in
  [`hotty-blitz-ref`](https://github.com/neuroplastio/hottyterm/blob/main/hotty-blitz-ref), which a release's notes name too. From hotty-blitz 0.0.3 (8330fbf), a
  program changes a document with `a=delta` (SPEC §6, hotty d2da455), and
  `a=patch`, its name before, is refused as an unknown action: a program
  needs hotty-go 57fdbb0 or later, or the same rename in its own SDK.
- **Native, not the polyfill.** A program sends hottyterm OSC 7279 and
  nothing else, and the linked hotty-blitz lays out and renders every
  surface. No kitty graphics crosses the pty: turning surfaces into kitty
  graphics is what the polyfill (`hotty run`) does for terminals that are
  not hosts.
- **Drawn through Ghostty's image layer.** Inside the process, a surface's
  pixels go into the active screen's image storage (the one Ghostty keeps
  for kitty graphics), placed at the cursor by a command built in memory. A
  placement therefore scrolls with its line, goes with an erase, and leaves
  with its screen, as SPEC §5.4 and SDK §4.3 expect of any host.
- **Updates replace only the damaged rectangles** of those pixels, as
  in-memory frame edits (`a=f`, `r=1`). After a font size change, surfaces
  re-render at the new cell size and replace their pixels in place.
- **Animated images play** (GIF, APNG, WebP): hotty-blitz says when the next
  frame is due, and a timer on the IO thread's loop renders it then, as a
  frame edit of the image's box. Nothing plays, no timer.
- **`fit`** (SPEC §5.2): hotty-blitz finds it as it renders a placement made
  with `f=1`; hotty.zig sends it to the program with the replies, from
  whichever path drew the frame.
- **`hover`** (SPEC §9.4): a placement made with `v=1` hears the nearest id
  under the pointer each time it changes, and out when the pointer leaves.
  hotty-blitz finds it from the moves and leaves it already gets for
  `:hover`; hotty.zig also leaves the surface when the pointer leaves the
  window (the apprts' move to -1, -1), unless a press holds the pointer.
- **Scrolling** (SPEC §5.3): a document that asks to scroll (`a=doc
  scroll=1`, `2` or `3`) takes the wheel, a touchpad's scroll or a touch drag
  while something in it can move that way; where nothing can, the gesture
  reaches the cells as if it were over them, unless `overscroll-behavior`
  stops it. hotty-blitz decides, through `hotty_host_wheel`.
- **Touch** (SPEC §9.1, GTK only): every phase of a touch that begins over a
  surface goes to hotty-blitz (`hotty_host_touch`), taps included, and none
  to the pointer. It drags when the touched element opts in to drags and its
  `touch-action` allows no pan along the first move past 8 CSS px; its moves
  wait until then. Otherwise it pans, and the terminal scrolls with it as
  with any touch, from where the finger touched (`hotty_host_wheel` first).
  A second finger cancels it. A touch over the cells is the terminal's, and
  taps and scrolls where the finger is, not where the mouse pointer was.
  hottyterm takes no long press. `scripts/touch-check.py` is the manual
  check, since the headless display cannot touch.
- **Touch in the cells** (SPEC §9.6, GTK only): a program that reports the
  mouse names areas of its cells with `a=touch`, kept per screen. A touch
  that begins in one, while the viewport shows the screen and without Alt,
  goes to hotty-blitz (`hotty_host_cells_touch`) and waits as on a surface:
  a drag the area's way, or a tap, is the mouse's primary button, which
  Ghostty reports itself (`mouseReport`) in the program's mode and
  encoding, from the press where the touch began to the release where it
  lifts, or on the last cell at a second finger. Nothing is selected, and
  Shift is left out unless `mouse-shift-capture` gives it to the program.
  The other way scrolls as any touch does. `scripts/touch-cells-check.py`
  is the manual check.
- **The network** (SPEC §7.2): surfaces fetch nothing until the user grants
  it with `hotty-net`, in CSP syntax (`hotty-net = img-src https:`), and then
  only what a document also asks for with `<meta name="hotty-network">`.
  The capabilities report the grant as `net`. hotty-blitz fetches on threads
  of its own (8 MiB and 10 s at most each, no referrer or cookies); what
  arrives wakes the IO thread through an `xev.Async`, which draws it as the
  animation timer does, and sends `fit` if it changed the height.
- **A CSS px is a logical pixel,** as in a browser: the scale (device px per
  CSS px) is the display's content scale, from the DPI Ghostty loads the
  font at (2 on a Retina Mac, 1.25 on a Linux display scaled to 125%). The
  host stylesheet's root font-size is the terminal's font in CSS px (14px
  for `font-size = 14` on macOS, 16px for 12 on Linux). `HOTTY_SCALE`
  overrides the scale.
- **Keys:** a surface that holds the keyboard is offered each key at most
  twice (SPEC §10.4). First, before Ghostty's shortcuts, a key that types
  no text (not a character, or a character with Ctrl or Super) as
  pressed, named as myelin key names 0.1 name it, with Super (Cmd) as
  `super`: a physical key (`ctrl+key_w`) and the character it types
  (`ctrl+w`), the physical one first, and a modifier tapped alone on its
  release (`shift_left`, `shift`). shift+home selects in a field instead
  of scrolling, ctrl+shift+arrow_left selects a word instead of switching
  tabs, and on macOS super+arrow_left goes to the line's start instead of
  becoming the 0x01 a binding writes. Then, if it did not
  use it, the shortcuts take their keys, and the rest is offered as the
  bytes the program would read: Ghostty's own encoding, in the modes the
  program set, and what a binding writes (`text:`, `csi:`, `esc:`,
  `cursor_key`). Typing comes only that way, so a field sees what a TUI
  would, whatever the layout or a remap made of it. What the surface does
  not use reaches the program as it came. hottyterm keeps for itself, as a
  browser keeps ctrl+t: every key during a key sequence or a key table,
  a key that starts a sequence, and keys bound to opening windows and
  splits, closing them, quitting, full screen and the config, and the copy
  keys while something is selected.
- **Copying** (SPEC §10.2, §11): a surface's selection is copied by its
  keymap, ctrl+c and super+c (Cmd+c) by default, and the program does not hear
  the key; a program that binds one to `program` hears it while text is
  selected. With nothing selected, or in a password field, it reaches the
  program, so Control+c interrupts. Control+Shift+c and the menu copy a
  surface's selection whatever its keymap says. On the cells, Control+c
  and Cmd+c copy what is selected; they are `performable:` bindings, so
  `keybind = ctrl+c=unbind` turns one off. A release on a surface copies
  its selection per `copy-on-select` (PRIMARY by default on Linux), a
  double click selects a word and a triple click a block, and there is one
  selection: a press on a surface drops the terminal's, and one on the
  cells the surfaces'.
- **Cutting, pasting and undo** (SPEC §10.2, §11): in a surface's focused
  text field, Control+x and Cmd+x cut, Control+v and Cmd+v paste,
  Control+z and Cmd+z undo, and Control+Shift+z, Control+y and
  Cmd+Shift+z redo, each field with its own history. Outside a field they
  reach the program as before, and so does a cut with nothing selected.
  While a field has the keyboard, the terminal's own paste
  (Control+Shift+v, Cmd+v, the menu) types into it, not into the pty. A
  program reading the clipboard with OSC 52 still asks first
  (`clipboard-read`).
- **The program's menu** (SPEC §10.7): a program's menu is kept per
  screen, and leaving the alternate screen drops that screen's. hottyterm
  shows the level a program's keys reach, and adds no palette of its own
  for the menu: a pick comes from a multiplexer such as plx, or from
  whatever drives the program.
- **No tabs:** plx has them, and windows and full screen are the window
  manager's. hottyterm has no default keys for tabs, quitting, closing
  windows or full screen, on the Mac too, and a new tab, from a binding, a
  menu or `+new-tab`, opens a window. Splits stay.
- **Cmd as Ctrl on the Mac:** `key-remap = super=ctrl` and `key-remap =
  ctrl=super` swap them, so the key macOS calls Cmd is Ctrl in the terminal,
  as on Linux. The menu sees keys as remapped (`0024`): a key that becomes
  no Command key never reaches it (Cmd+H is Ctrl+H, not Hide), and one that
  becomes a Command key does. hottyterm also reads
  `~/Library/Application Support/io.github.neuroplastio.hottyterm/config.ghostty`
  after `~/.config/ghostty/config`, for settings Ghostty itself should not
  get.
- **Input:** clicks and
  hover on a surface go to it and are not reported to the program as mouse
  input, except where it takes no pointer (`pointer-events: none`, SPEC
  §9.3): there they pass through to the window below or to the cells, and
  the program hears them as over any cell. A program that placed the surface
  with `p=1` hears each press on it as a hotty `press` event instead (SPEC
  §9, from hotty-blitz). A press holds the pointer until its release: one on
  a surface keeps it for that surface, one on the cells keeps it for the
  program, so a drag that starts on cells is reported whole, even over a
  surface. A press with Alt held (Option on macOS, whatever
  `macos-option-as-alt` says) is the program's wherever it lands (SPEC
  §9.2): no surface gets it, it is reported as mouse input with Alt set, and
  it holds the pointer for the program, so an alt+drag that starts over a
  surface (plx moving a tool or a pane) works.

## Install

Every release is a [GitHub prerelease](https://github.com/neuroplastio/hottyterm/releases):

- **macOS**, Apple silicon: `brew install --cask neuroplastio/tap/hottyterm`,
  or the release's `hottyterm-<version>-macos-arm64.zip`. The app is signed ad
  hoc, not notarized. The cask takes macOS's quarantine off it, so it opens
  without asking; from the zip, macOS blocks its first launch until it is
  allowed in System Settings, Privacy & Security ("Open Anyway"). `brew
  upgrade` updates it; the app never looks for updates itself.
- **Arch Linux**, x86_64: the AUR package
  [`hottyterm-bin`](https://aur.archlinux.org/packages/hottyterm-bin)
  (`yay -S hottyterm-bin`, or any AUR helper). It installs the release's Linux
  build in `/usr/lib/hottyterm`, with `hottyterm` on the PATH, its desktop
  entry and its icons.
- **Other Linux**, x86_64: the release's
  `hottyterm-<version>-linux-x86_64.tar.gz`, unpacked anywhere
  (`bin/hottyterm` finds its library through a relative rpath). It needs GTK 4,
  libadwaita and gtk4-layer-shell, and is built on Arch Linux, so it needs a
  glibc as recent as Arch's.

## Settings

hottyterm reads Ghostty's config file (`~/.config/ghostty/config`) and adds three
settings to it.

### `hotty-net`: what surfaces may fetch

The terminal's half of the network policy (SPEC §7.2), in the syntax of a
Content Security Policy: directives separated by `;`, each followed by its
sources. **The default is empty: surfaces fetch nothing** and show only what the
program sent them. hotty leaves the network to the user: every fetch tells a
server that a document is being shown, and where from, so a terminal starts with
nothing granted (§7.2).

| `hotty-net =` | surfaces may fetch |
| --- | --- |
| (no line, or empty) | nothing: the default |
| `img-src https:` | images from any HTTPS origin |
| `img-src https://example.com` | images from that origin only |
| `img-src https:; font-src https:; style-src https:` | images, fonts and stylesheets over HTTPS |
| `img-src http://localhost:8080` | images from a local server, over plain HTTP |

- **Directives:** `img-src` (`<img>`, `srcset`, `<picture>`, images in SVG and
  in CSS), `style-src` (stylesheets, `@import`), `font-src` (`@font-face`);
  `media-src` is accepted, and nothing plays.
- **Sources:** an origin (`https://example.com`, `http://localhost:8080`) or
  `https:`, every HTTPS origin. Plain `http` only where an origin names it.
- **The document asks too:** a URL is fetched only when the document's `<meta
  name="hotty-network" content="img-src https:">` also allows it. A program
  that does not ask fetches nothing, whatever this grants.
- **Never:** files, documents (`<iframe>`), referrers, cookies or credentials.
  A redirect is followed only where the policy allows its target. At most 8 MiB
  and 10 seconds per fetch.
- **Turning it off or narrowing it:** delete the line, empty it, or list fewer
  sources, and reload the config (`reload_config`). Documents shown already are
  held to the new policy from their next request.
- Programs see the grant in the capabilities, as `net` (SPEC §4).
- Ghostty does not know the key: going back to Ghostty, delete the line.

### `hotty-link-click`: how a click opens a link

**A plain click opens a link,** as in a browser: an OSC 8 hyperlink in the
cells, or a hyperlink in a surface. The pointer over one underlines it. A URL
or a file path that Ghostty finds in the cells' text opens with Ctrl (Cmd on
macOS) and click, as in Ghostty: it is a guess, and a prompt's directory, or
the first row of a URL a multiplexer drew row by row, would open as one.

| `hotty-link-click =` | a link opens with |
| --- | --- |
| `plain` (the default) | a click, or Ctrl (Cmd on macOS) and click |
| `modifier` | Ctrl (Cmd on macOS) and click only, as in Ghostty |

- **A drag still selects:** a link opens on a release that did not drag.
- **A program that reports the mouse** (an editor, a multiplexer, Claude
  Code's full screen) doesn't get a plain click on an OSC 8 hyperlink: the
  link opens. A drag that starts on one still reaches the program, and a
  click with a modifier is the program's, as without this setting.
- A link that opens is handed to the desktop as Ghostty hands it: the same
  opener, and on macOS the same allowlist and confirmation for OSC 8 links.
- Ghostty does not know the key: going back to Ghostty, delete the line.

### `hotty-focus-click`: the click that focuses a terminal

**The click that focuses a terminal clicks too,** as in a browser: the first
click on a window in the background, or on a split without focus, also presses
the surface's button, opens the link, or reaches the program as a mouse event.

| `hotty-focus-click =` | the click that gives a terminal focus |
| --- | --- |
| `pass` (the default) | focuses it and clicks |
| `focus` | only focuses it, as in Ghostty |

- With `pass`, a click only meant to bring hottyterm forward lands on whatever
  is under the pointer: in vim it moves the cursor, in tmux it can switch the
  pane. That is why Ghostty drops it; set `focus` to have that back.
- Ghostty does not know the key: going back to Ghostty, delete the line.

## Branding

The fork is branded by a generated commit on top of the patches:
`scripts/brand.py` applies [`brand/brand.toml`](https://github.com/neuroplastio/hottyterm/blob/main/brand/brand.toml), the icons in
`brand/icons` and the files in `brand/overlay`, checks, and commits. It drops
the previous branding commit first, so the branding is regenerated, never
rebased, and pulling in upstream costs nothing here unless an anchor moved.

- **Anchored rules** replace exact strings (the application id, the About
  dialog, the executable name, desktop files), each with an expected count. If
  upstream moves one, brand.py names the file and the string.
- **Translatable strings** (`_("…")`, `i18n._("…")`, `i18n.N_("…")`) and the
  translations in `po/` have the name replaced wherever it appears, so new
  strings upstream adds are covered without a rule.
- **Overlays** replace whole files: the icons and the AppStream metadata.
- **The check** fails if a user-facing string in the GTK app, the CLI's version,
  or the desktop files still names Ghostty or uses its application id. Comments,
  logs, "a fork of Ghostty" and the translations' copyright holder are allowed.

What changes: the name, the icon, the application id
(`io.github.neuroplastio.hottyterm`: window class, D-Bus name, desktop entry,
notifications), the executable (`hottyterm`), the About dialog and user-facing
strings. What stays, because programs and configs rely on it to work with
Ghostty: `TERM=xterm-ghostty`, `TERM_PROGRAM`, the `GHOSTTY_*` variables, the
config file (`~/.config/ghostty/config`) and the resources directory
(`share/ghostty`). The man pages and shell completions are still Ghostty's and
name `ghostty`; they matter only for an install.

## Build and run

Needs a checkout of [hotty-blitz](https://hotty.neuroplast.io/docs/hotty-blitz/)
next to this repository (or `HOTTY_BLITZ_DIR`), and
[hotty](https://hotty.neuroplast.io/docs/) for the examples.

```sh
mise install
make build                    # sets up ../ghostty, builds hotty-blitz and the fork
scripts/run.sh -e python3 ../../hotty/main/examples/dash.py
make check                    # mods described, patches apply, build, smoke test
scripts/perf.sh               # what dash.py costs, per thread (docs/performance.md)
```

The binary is `../ghostty/zig-out/bin/hottyterm`. Its application id is its own,
so it runs next to an installed Ghostty.

A local build takes the hotty-blitz checkout as it is, and says so when that is
not the commit in `hotty-blitz-ref`, which CI and every release build.

`scripts/build.sh` carries two workarounds for this machine: Zig 0.16 needs
LLVM and LLD to link CachyOS's `crt1.o`, and a cached blueprint-compiler 0.16
whose warnings on upstream's UI files are hidden unless it fails.

### macOS

`scripts/build-macos.sh` builds `out/hottyterm.app` for Apple silicon on a Mac
with Xcode 26: GhosttyKit (the framework the app links) as upstream builds it,
then the app in Xcode's `ReleaseLocal` configuration, signed ad hoc, with
hotty-blitz's static library added to its link. macOS blocks the first launch
until it is allowed in System Settings, Privacy & Security. The branding covers
the macOS app too: bundle id `io.github.neuroplastio.hottyterm`, name, icon
(Icon Composer bundle and image sets), menus, AppleScript dictionary and Swift
strings. It never looks for updates: Ghostty's update feed would replace it with
Ghostty. Still Ghostty's: the executable inside the bundle
(`Contents/MacOS/ghostty`), and the parts the "custom icon" setting composes an
icon from.

## CI

One workflow, `.github/workflows/ci.yml`:

| job | when | what |
| --- | --- | --- |
| `linux` | every push and pull request | in an Arch Linux container: the patches apply, the branding finds its anchors, the fork builds; artifact `hottyterm-linux-x86_64` (`bin/`, `share/`, and `lib/libhotty_blitz.so`) |
| `macos` | a push to main that changes the fork's inputs (`ghostty-ref`, `hotty-blitz-ref`, `mods/`, `brand/`, `notices/`, the scripts, the workflow), or on demand | `macos-26`, Xcode 26.6, `scripts/build-macos.sh`; artifact `hottyterm-macos-arm64` |
| `canary` | daily, or on demand | the patches and the branding against upstream's latest main |
| `release` | on main, when both builds ran and passed | a GitHub prerelease with both artifacts and `SHA256SUMS` |
| `homebrew` | after a release | `packaging/homebrew/publish.sh`: the cask `hottyterm` in [neuroplastio/homebrew-tap](https://github.com/neuroplastio/homebrew-tap), rendered from `packaging/homebrew/hottyterm.rb` with the release's app; writes with the `HOMEBREW_TAP_DEPLOY_KEY` secret, the private half of a deploy key on the tap |
| `aur` | after a release | `packaging/aur/publish.sh`: [`hottyterm-bin`](https://aur.archlinux.org/packages/hottyterm-bin) on the AUR, rendered from `packaging/aur/PKGBUILD` with the release's Linux build and built with makepkg first; writes with the `AUR_SSH_PRIVATE_KEY` secret, a key of the AUR account |

Every build carries `LICENSE` and `THIRD-PARTY-NOTICES.txt` (the Linux archive's
top, the app's `Contents/Resources`), and each release attaches them as files of
their own. `scripts/notices.py` writes them from what the build contained: every
Zig package Ghostty's build used, every Rust crate linked into hotty-blitz, the
Zig and Rust standard libraries, and Sparkle in the macOS app, with their licence
texts. It fails on a component with no licence it can find;
`notices/overrides.toml` records, checked by hand, what upstream says for
packages that ship none. `make notices` writes this machine's.

Releases are versioned like the other neuroplastio projects: CalVer from the
commit's UTC date, then the short commit, e.g. `26.09.29-dev.1e81d70`. The tag is
the version; the notes name the Ghostty and hotty-blitz commits the build used. A
docs-only push builds on Linux but makes no release; run the workflow by hand for
one. A change in hotty-blitz reaches a release when `hotty-blitz-ref` moves to it,
a commit here like any other.

A small `changes` job decides whether a push touches the fork's inputs, so a Mac
is not started only to skip. `gh workflow run ci` runs everything by hand.
Artifacts are kept for 7 days. The smoke test needs a display and a GPU and stays
local.

## Changing the fork

1. `scripts/fork.sh` sets up `../ghostty` (branch `hottyterm`: the patches, then
   the branding) if missing.
2. Change it there and commit. Run `scripts/brand.py`, which moves the branding
   back to the top, then `scripts/export.sh`, which exports everything but the
   branding.
3. Write a `.md` for a new patch (`make mods` names what is missing), and
   commit `mods/ghostty/` here.

To change the branding, edit `brand/` and run `scripts/brand.py`; never edit the
branding commit.

`scripts/canary.sh` applies the patches to upstream's latest `main` in a
throwaway worktree, dry-runs the branding on it, and reports conflicts and moved
anchors. Moving `ghostty-ref` is a deliberate step: canary; in the fork, `git
reset --hard HEAD~1` (the branding) and `git rebase <new ref>`; `scripts/brand.py`;
build, smoke test; export; then commit `ghostty-ref`, `mods/ghostty/` and
the `base` in its `mod.md` together.

Moving `hotty-blitz-ref` is the same kind of step: `make pin-blitz` writes
hotty-blitz's main as pushed (or `scripts/pin-blitz.sh <commit>`, one on that
main); `make check` with that commit checked out; then commit `hotty-blitz-ref`,
with any patches that need it.

## Verified (2026-09-29)

On a private headless display (hotty-blitz's `scripts/headless.sh`):

- `dash.py`: 56 frames in 6 s, 55 of them partial; no kitty graphics failures.
- `form.py`: typing, Tab, a change event back to the program, the checkbox and
  the radio by click.
- Bub-n-Bros (`bubbros.py`): 383 sprites, about 7 changes a frame, 20 fps, keys
  through the kitty keyboard protocol.
- Cost: the terminal process uses 8–9% CPU on the 10 Hz dashboard. An earlier
  fork, which uploaded only the changed rectangles to the GPU, used 6–7%.
- Measured again on 2026-10-10 at 3.5 Mpx, release by release: the dashboard
  went from about 15% of a core to 8%. The fork contributes region uploads on OpenGL (`0020`) and
  Metal (`0025`), and rectangles written straight into the image (`0023`);
  hotty-blitz contributes its own changes.
  [docs/performance.md](https://github.com/neuroplastio/hottyterm/blob/main/docs/performance.md) has the numbers, what was tried,
  and how to measure.

Headless clicks need a keyboard: Ghostty takes a left click on an unfocused
surface as a focus click and does not report it, and the headless display has no
keyboard unless `wtype` is running.

## Licence

MIT ([LICENSE](https://github.com/neuroplastio/hottyterm/blob/main/LICENSE)), like Ghostty, whose source the patches change
([LICENSE.ghostty](https://github.com/neuroplastio/hottyterm/blob/main/LICENSE.ghostty)). hotty-blitz, which it links, is
Apache-2.0. Releases carry every third-party notice (`scripts/notices.py`).
