xterm-addon-hotty: hotty for xterm.js
A hotty host as an addon for xterm.js, where the browser is the engine. Every surface is a real document in a sandboxed iframe. It shares no code with hotty-blitz, the other implementation, and passes the same conformance vectors. The hotty repository's example programs run unchanged in a browser tab.
npm install @neuroplastio/xterm-addon-hotty @xterm/xterm
The package is an ES module with its TypeScript declarations, for xterm.js
6, and has no dependencies. A commit not yet released installs from git
(github:neuroplastio/xterm-addon-hotty#<commit>): npm builds it as it
installs it (prepare).
It implements hotty 0.4, whose bodies are msgpack maps (SPEC §3.3) and
whose keys are named by myelin key names 0.1 (§10.4). A
program's query names the versions it speaks (a=q:n=1:v=0.4, SPEC §4):
the addon answers a query that lists 0.4 with its capabilities (v,
cellcss, scale, scheme, mod, features, limits, net, edit,
host, version), and any other with EVERSION, so that such a program
finds no host and falls back to text. A command that asks for a reply (n)
gets one; a query always does, and every other command is quiet unless it
carries n.
import { Terminal } from "@xterm/xterm";
import { HottyAddon } from "@neuroplastio/xterm-addon-hotty";
const term = new Terminal({
// Optional: key releases for programs that need them (games), SPEC §10.3.
vtExtensions: { kittyKeyboard: true },
});
term.loadAddon(new HottyAddon());
term.open(element);
// term.onData → your pty, as usual: replies and events travel that way too.
Links and the network
A surface fetches nothing from the network unless the page grants it, and the document asks for it (SPEC §7.2):
new HottyAddon({
// The host's half of the network policy: directive → origins, or "https:".
network: { "img-src": ["https://example.com"] },
});
- A document asks with
<meta name="hotty-network" content="img-src https://example.com">and gets what both allow; its<base href>sets its base URL, so relative images and links resolve (§7.3). The capability reply reports the grant asnet. - Links show, hover and copy as the links they are. A click is a
clickevent for the program, withhref(the program's value) andurl(resolved), whether the link has anidor not. The addon never opens one itself (§9). The context menu's own "open in new tab" and "copy link" work as on any page. - Hyperlinks, links with
target="_blank"and aurl, are the terminal's, as OSC 8 links are (§9). One without aurl(a relativehrefwith no base, say) is the program's, as any other link. They go where xterm.js sends an OSC 8 link: the terminal'slinkHandler(activate,hover,leave, with the link's cells as the range), or xterm.js's confirm-then-open default. Onlyhttpandhttpsgo through, unless the handler setsallowNonHttpProtocols. The program hears nothing of them. - The page's own CSP must allow the granted origins too (below).
Try it
Clone neuroplastio/hotty next to
this repository (or set HOTTY_DIR), then:
npm ci && npm run build
python3 serve.py --examples # /?run=card, /?run=dash, /?run=form, /?run=grid, /?run=bubbros
python3 serve.py -- python3 ../hotty/examples/dash.py # any program, at /
serve.py is the test bridge: an HTTP server for dist/ and a WebSocket to a
pty. It uses the standard library only, and listens on 127.0.0.1 because it
runs programs for whoever connects.
How it works
| piece | file | what it does |
|---|---|---|
| wire | src/wire.ts |
OSC 7279 control parsing, chunk reassembly, base64, zlib through DecompressionStream, and reply encoding |
| bodies | src/msgpack.ts |
SPEC §3.3: replies' and events' bodies as msgpack maps, each field as its type; a float field (scale, cellcss's w and h) goes in a Float, even when whole. A field with nothing is left out, never nil, and a map is a plain object, keyed by strings, each written once |
| addon | src/addon.ts |
OSC handler, replies through term.input(…, false), placement, synchronized output, RIS, the alternate screen, zoom and theme, and keys a surface does not use, through xterm.js's own keyboard handling |
| surface | src/surface.ts |
one sandboxed iframe per surface; events, focus and key routing |
| keys | src/keys.ts |
SPEC §10.2, §10.4: key names, the keys in what the terminal sends, keymaps (data-keys), the prefix action, and the actions on a field's text |
| deltas | src/delta.ts |
SPEC §6, §6.1: a delta body checked whole, then its ops and morph; a failure names the first failing op (i) |
| menus | src/menu.ts |
SPEC §10.7: reading a program's a=menu body, the level a sequence reaches, and the entries a page's own palette lists |
| touch | src/touch.ts |
SPEC §9, §9.1, §9.6: a finger's drag as wheel events and its fling, a tap, a drag a surface claims, and reading the areas a program names in the cells |
| reveal and view | src/view.ts |
SPEC §6.1, §9.5: the boxes that hold an element, scrolling it into view in them, and whether some of it is shown |
| resources | src/resources.ts, src/resolver.ts |
cid: as blob: URLs; sanitizing everything before it reaches a live document |
| host stylesheet | src/hostcss.ts |
§8 from term.options (theme, font, cell size), in a cascade layer: the palette, --hotty-accent, and controls, focus, links and selection in the terminal's colours |
-
Placement. On the normal buffer a surface hangs from a marker, which is public API (decorations need
allowProposedApi). It scrolls with the text, stays visible while any of its rows is, and dies when its line leaves the scrollback. On the alternate buffer, where markers do not work, it sits on fixed cells and dies with the screen. The iframe never moves in the DOM, since that would reload it; only its box moves. -
Windows and hiding (SPEC §5.2, §5.4). The iframe keeps the surface's whole size inside a box the size of the window, offset by the window's corner, so a new window moves the iframe and lays nothing out. A hidden surface's box is
display: none: its document stays, and the browser skips its style, layout and paint until it is placed again. -
A transparent document shows the cells beneath it. The frame's colour scheme is the document's (the theme's, as the host stylesheet declares it): were they to differ, the browser would paint the frame an opaque canvas.
-
Stacking (SPEC §5.2). A placement's
zis its box'sz-index, and the boxes stand in the layer in the order their surfaces were created, so overlapping placements stack as the spec says, and the browser gives the pointer to the topmost. -
The keyboard (SPEC §10.1). A click takes it only by focusing an element that takes focus: an
input,select,textareaorbutton, a link with anhrefthat is not a hyperlink, a details' firstsummary, an editing host, or atabindexof 0 or more (alabelcounts as its control). A click on anything else, a hyperlink included, sends nofocus, and on a surface that had the keyboard it sendsblur. The browser focuses the frame on any click; once the press is done, the addon gives that focus back to the terminal, so text selection works as usual. A right click leaves focus where the browser put it, so the context menu's Copy copies the surface's selection. -
A text field's keys (SPEC §10.2, §10.4) are the program's keymap. A key that types no text (not a character, or a character with
ctrlorsuper) is offered first as pressed, named from the DOM's event by its physical key (code:ctrl+key_w) and the character it types (key:ctrl+w), the physical one looked up first, before xterm.js translates it: on a Mac, Option+Left isalt+arrow_left, not the ESC b the program would read. A modifier tapped on its own is named on its release (shift_left,shift), which matters to a key bound toprefix. Otherwise the key goes through xterm.js's keyboard handling, with what it would send the program kept back, and is named from that, so a field sees the key the program would read, in the encoding the program enabled. The field's keymap (the default, then eachdata-keysfrom the root to the field) decides: an action, which the addon does itself on the field's text with the editing commands, so the browser sendsinputandchangeas for typing; a character, which the browser types; or the program's, which gets what xterm.js encoded. Details:- Enter that submits, in an
input, is the browser's own implicit submission; another key bound tosubmitdoes the same through the form's default button, or the form. - A textarea's rows are as it wraps them: a hidden copy of its text, with the same width and font, measures where each position is.
- An email or number field, whose caret the browser keeps to itself, is
textwhile focused (inspection still reports its type, andinputmodekeeps the on-screen keyboard), and a number field types only what a number holds. CSS that selects on[type=email]or[type=number]does not match it while it is focused. - An editing host's actions use the selection's moves, a character at a time where the words and lines need to see the text.
- Enter that submits, in an
-
Keys for the program (SPEC §10.2). Any focused element has a keymap: each
data-keysfrom the root to it, over the default only in a text field; with nothing focused there is none. A key it binds toprogram(named as in a text field; a key withshiftthat is not a character is looked up again without it) goes to the program as xterm.js encoded it, and the browser does nothing with it: a select's arrows pick nothing, and a document that scrolls does not scroll. Outside a text field the keymap's other bindings do nothing but the scroll actions (below), and a nearer one of a key still cancels a fartherprogram. -
Scrolling keys (SPEC §10.2). Outside a text field, a key the focused element does not use and its keymap binds to a scroll action (
scroll-down,scroll-page-up,scroll-half-page-down,scroll-end, …) scrolls, along an axis the document asked for: the nearest box from the element outward that can still move that way, as a wheel goes, up to the root and never the terminal; with nowhere to go, it does nothing and the program hears nothing. The addon scrolls as Chromium's keys do: 40 CSS pixels for a line, 87.5% of the box for a page, half of it for a half page, and to the end forscroll-startandscroll-end. Along an axis the document did not ask for, the key goes on as if unbound. A text field's keymap leaves scroll actions out, so a field inside a box that scrolls withjstill typesj. -
Selects (SPEC §10.2) are the browser's own: its keys pick, each pick is heard at once (
inputwithdata-on~=input, thenchange), and its native list opens past the surface. While the list is open it has every key: Chromium's keeps them from the document, and where one reaches it (:open), the addon leaves it to the list. -
Drags (SPEC §9.1, a draft on hotty's
dragbranch). A mouse's or a pen's primary press on an element withdragin itsdata-onand an id reportsdragstart, thendrageach time the element under the pointer changes (each cell while there is none), thendragend, with the surface's cell and the keys held. The frame's root element captures the pointer (setPointerCapture) until the release, so the moves keep coming over the cells, other surfaces and outside the page, and none reach xterm.js's mouse reporting; the root is never replaced, so deltas do not lose the capture. The browser clicks the root on a captured release, so the drag reports the click itself when it ends where it began. The host stylesheet makes the elements that opt in unselectable, important in its layer, whatever the document's CSS.A touch drags such an element only where its
touch-actionallows no pan along the touch's first move past 8 px (the larger delta; a tie is a pan). The value is the element's own, met with its ancestors' up to the nearest one that scrolls, both included, as Pointer Events have it; the host's owntouch-actionon the root (below) is not the document's. The touch then reportspressanddragstartat the cell where it began, and drags from there as a mouse does; the lift reportsdragend, and the click where it began. A second finger ends it withdragendand no target, and nothing more is heard until every finger lifts. A long press, or Alt held at the touch, never drags. Any other touch pans as below.An element that also has
data-steps="<x> [<y>]"(whole numbers up to 2^53 − 1, 0 for none along that axis) says where in it the pointer is: every event of a drag of it carriesxandy, the pointer's step along its border box (getBoundingClientRect, as it is at each move), rounded to the nearest and clamped to 0 and the count, wherever the pointer or the finger is. Adragalso goes out each time the step changes. The counts are read when the drag starts; once the element has left the document, the step stays as it last was, as it does in adragendthat ends a drag early. The capabilities reportsteps: true. -
Presses (SPEC §5.2, §9:
p=1ona=place). Every primary press of a mouse or a pen in the window reportspresson its pointerdown, before the drag and the focus it causes; a tap reports it on the mousedown the browser makes for it, and a touch that drags when it becomes a drag. A touch that pans presses nothing. A press on a hyperlink is the terminal's. -
Fit (SPEC §5.2, §9:
f=1ona=place). The program hearsfitwith the rows the document needs at the placement's width whenever they differ from the rows it heard last, starting from the placement's own; the placement keeps its size. The rows are measured as forr=auto: the frame is laid out at that width and 1px high, then restored in the same task. A check waits for the next frame, so a frame sends onefitat most, with the rows it draws. Each of these asks for a check:- a document or a delta;
- a resource arriving or changing;
- the host stylesheet changing (cell size, font);
- an image, a stylesheet or a font loading, whether a
cid:resource or one from the network; - the root's or the body's box changing size (a
<details>the user opens, say).
A surface out of view checks once it is shown again. A check lays the document out a second time, so only placements with
f=1pay for it. -
Presses with Alt (SPEC §9.2) are the program's. The frame cancels the press's mousedown (no focus, selection, drag or hyperlink), reports nothing, and captures the pointer on its root until the release; the addon replays the press on xterm.js's screen and the moves and release on its document, where xterm.js listens, as it replays wheels: a mouse report with Alt, or with reporting off, xterm.js's rectangular selection. A surface that had the keyboard gives it back first (
blur). The browser's click on the release is not the surface's. Firefox drops the pressed element's:hoveronly at the next move. -
Detached surfaces (SPEC §5.5:
a=detach, ord=1ona=doc) send no events and never take the keyboard. Theirinput,select,textareaandbuttonelements carry adisabledof the addon's own, which inspection and morphs do not see, and which a delta cannot remove. Hover, selection,<details>and hyperlinks work as before. Only a hyperlink shows the hand, whatever the document'scursor; other links show the text pointer. -
The browser's keys stay the browser's (the
browserKeysoption):- reload (F5, and Ctrl or Cmd with R);
- zoom (Ctrl or Cmd with +, − or 0);
- full screen (F11);
- the developer tools (F12, and Ctrl+Shift with I, J or C);
- on a Mac, everything with Cmd.
Neither the terminal nor a surface holding the keyboard sends them to the program, and the browser acts on them. A terminal cannot know which keys a program binds, so these are the browser's own. The addon installs xterm.js's custom key handler for this, so a page passes its own list here, not to xterm.js. The default is exported, so a page can add keys of its own:
import { HottyAddon, browserKeys } from "@neuroplastio/xterm-addon-hotty"; new HottyAddon({ browserKeys: (e) => browserKeys(e) || (e.ctrlKey && e.key === "k") }); -
Copying (SPEC §10.2, §11): a surface's selection is copied by its keymap,
mod+cby default (Cmd+C on a Mac, Ctrl+C elsewhere:modissuperorctrl, the platform's, §10.4), and neither the surface nor the program hears the key; a document that binds one toprogramgives it to the program while its text is selected. A focused field copies its selection, never a password field's. With nothing selected the key goes where it would: Ctrl+C interrupts. The cells' selection is copied by thecopyKeysoption (default: the exportedcopyKeys, Ctrl+C and Cmd+C;() => falseturns it off). There is one selection: a press on the cells, or a selection made there, drops a surface's, and a press in a surface drops the others'. The browser's own Copy menu copies what is selected whatever the keymaps say. -
Cutting, pasting and undo (SPEC §10.2): in a surface's focused text field, by its keymap, Ctrl+X cuts (to
navigator.clipboard), Ctrl+V is the browser's own paste (aninputdrops line breaks, as HTML's does), and Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y undo and redo, each field with its own history, kept by the addon so that every browser groups edits as the spec does. A key a document binds topastethat the browser does not paste with readsnavigator.clipboard. A cut with nothing selected, or in a password field, reaches the program. On a Mac, Cmd keys stay the browser's (browserKeys), its own cut, paste and undo. -
Prefix (SPEC §10.5): a key a keymap binds to
prefixis the program's, and while it is on every key, Tab and Escape included, goes to the program. It ends when the program focuses (a=focus, with nottoo) or blurs, when the surface loses the keyboard or is hidden, or on a press in the surface. The browser's own keys (browserKeys) stay the browser's. -
The program's menu (SPEC §10.7):
a=menuwith a body sets the program's menu, a tree of items (key,label,name,enabled,items) nested at most eight deep, anda=menuwith none leaves it none; a body of another shape isEINVAL. The addon keeps one for each screen: leaving the alternate screen drops its menu, RIS both.a=menu:show=1shows the level itsseqreaches (the items that have a key, each shown as myelin key names §6 shows it:←→↑↓,⇥and⇧⇥, and every other key as its canonical name) at the bottom right of the screen, on whole cells, for the cells and a surface alike; a menu sent meanwhile shows the same keys' level of the new one.show=0, Escape, a key the level does not list (typed to the cells or to a surface), another screen, or RIS hides it.shownMenu()returns the level shown ({key, label, group, enabled}[], or null), for tests. -
A pick, from the page's own palette (SPEC §10.7): the addon draws no palette, since xterm.js has none and a host adds none for a program's menu. A page that has a command palette lists a program's actions in it and runs them.
menuItems()returns the items of the active screen's menu that have a name, in the menu's order, each{name, label, keys, enabled}(the exportedMenuEntry):labelis its groups' labels and its own joined with" › "(Leader › File › New file; the name where it has no label), andkeysthe keys that reach it, space-separated, withmodread as the platform's, in myelin key names (ctrl+x f n; empty when no key does;displayKey()shows one as §6 does, with←forarrow_left).pick(name)runs one:commandwith{name}and no key, or forcopywith text selected on the cells, the selection copied and nothing sent, and forpastethe clipboard's text with it. It returns false, and sends nothing, for a disabled item, an unknown name, or no menu. Both follow the active screen and the lasta=menu, so a page readsmenuItems()each time its palette opens. -
Touch in the cells (SPEC §9.6):
a=touchnames cells of the screen where a finger drags,{"areas": [{"c", "r", "w", "h", "drag"}]}, kept per screen asa=editis: leaving the alternate screen drops its areas, RIS both, anda=touchwith no body leaves none. While the program reads the mouse (term.modes.mouseTrackingModeis notnone) and the terminal shows the screen, not its scrollback, a touch that begins in an area without Alt, and whose first move past 8 px goes a way the area drags (the larger delta; a tie pans), is the mouse's primary button. The addon replays it on the cells as a mouse's press, moves and release, as it replays a press with Alt on a surface, so xterm.js reports them in the program's own mode and encoding, with Ctrl, Alt and Meta as held, never Shift (with a press, xterm.js would select instead). A tap there is a click, as on any cell; a long press never drags; a second finger, or a touch the browser loses, releases on the last cell. Areas do nothing withtouch: false, withscroll: "page", where the cells' touches are the browser's, or with xterm.js'smouseEventsRequireAlt. In Chromium, a touch that lands within about 10 px of a surface is the surface's: the browser's touch adjustment gives it to the frame. -
Commands in the cells (SPEC §10.6):
a=editis kept per screen, and a key of a command taken goes to the program ascommand, after the cells' selection is copied (copyKeys). A paste brings the text of the browser's own paste where the key makes one, as on the cells; from a surface it readsnavigator.clipboard, and keys typed while that reads can reach the program first. -
Nothing in a surface scrolls unless its document asks (SPEC §5.3, §9):
-
It shows no scrollbars, and pans nothing on a touch (
touch-action: none). Any scroll offset the browser sets goes back to zero, except a text field's own text. -
A wheel over a surface goes to the terminal, and so does a touch that pans, as wheel events at the finger. After the finger lifts, the drag keeps going and slows down.
-
Taps and long presses stay the surface's.
-
On the cells, the addon handles touch too (the
touchoption, on by default), in place of xterm.js's own. A drag scrolls as over a surface. A tap is a click for the program: a press and a release at the finger. xterm.js 6.1's own touch handling sends wheel reports with no position (NaN, which a program reads as typing) and turns taps into nothing. -
Ctrl and the wheel stay the browser's zoom.
-
xterm.js's scrollable reads the legacy
wheelDeltaYwhere browsers have it, so a forwarded wheel carries one, worked out from the drag: on a constructed event the browser's own has the wrong sign in Chromium 15x. -
A drag is measured in the page, not in the surface, which moves as the terminal scrolls.
-
A page that scrolls itself (
scroll: "page"): for a page that shows a program's output whole, with the terminal as tall as what it shows, such as a document printed by a program. Wheels and touch drags over the surfaces and the cells are left to the browser, which scrolls the page natively, with its own momentum, and the program hears no wheel. xterm.js never sees them, since it would take a drag for its scrollback, or turn it into arrow keys. Surfaces pan on a touch (touch-action: manipulation), and over an element of the document that the browser would scroll instead (overflow: auto), the addon scrolls the page itself.new HottyAddon({ scroll: "page" });
-
-
A document that scrolls (
scroll=1,2or3ona=doc, SPEC §5.1, §5.3; the capabilities say"scroll": true):- Along the axes it asked for, the browser scrolls it as it scrolls a
page: the root and every
overflow: autoorscrollelement, with the browser's scrollbars, which take pixels inside the frame, never cells. The root pans on a touch along those axes (touch-action: pan-y,pan-x). - Along an axis it did not ask for, the root and every element whose
overflowthere isautoorscrollareoverflow: hidden, important in the host's layer: no scrollbar, and nothing the user does moves it. CSS cannot select by a computed value, so the addon reads it after each document, delta and host stylesheet, and marks those elements with an attribute of its own, which inspection and morphs do not see. - Gestures. A wheel gesture's first event decides where it goes, as
a browser latches scrolling to what it began on (a gesture is wheels
less than 150ms apart): to the document while the innermost box under
the pointer that can move that way can (the browser scrolls it), else
on to the terminal, as over the cells, unless
overscroll-behaviorstops it. A gesture begun over the cells stays the terminal's when a surface comes under the pointer. A touch that pans decides the same way once it has a direction: the browser pans the document, or the touch goes to the terminal as before. Where the document'stouch-actionwould stop the browser's pan (anoneon a box that doesn't drag), the addon pans the box itself, so that only a drag element takes a touch from scrolling (SPEC §9.1). - Keys. While the surface has the keyboard, the keys a browser scrolls with that the focused element neither uses nor gives the program (SPEC §10.2) scroll the innermost box, from the focused element outward, that can move that way: the arrows by 40 pixels, Page Up and Page Down, Space and Shift+Space by seven eighths of the box, Home and End to the ends. The addon scrolls the box itself, since the browser's own action may be the element's (a radio button's arrows). Where nothing can move that way, the key goes on to the program, as any key the surface does not use.
- Focus scrolls an element into view, as the browser does, and so does
reveal(below). The program hears of scrolling only throughview(below). A delta keeps the offsets, and so do hiding and placing again; a new document starts at the top left.r=autoandfitmeasure the document with its root clipped, so the root's scrollbar does not make its lines wrap. - Where the page scrolls (
scroll: "page"), the browser chains a gesture from the document to the page natively.
- Along the axes it asked for, the browser scrolls it as it scrolls a
page: the root and every
-
Where an element is (SPEC §9:
areaonclick,pressandview): the cells of its bounding box in the frame, as the user sees it, scrolled included, divided by the cell size. An edge within half a device pixel of a cell's counts as on it, since layout rounds positions to fractions of a pixel. A press with no id carries none. -
Reveal (SPEC §6.1:
op=reveal, the capabilities'opslist it) scrolls the boxes that hold the target, innermost first and the root last, asscrollIntoViewwould withstart,endornearest, but only those the document scrolls (overflow: autoorscrollalong an axis it asked for, and the root), and never one outside the document: the browser's ownscrollIntoViewwould scroll the box that shows the window, xterm.js's and the page too, andoverflow: hiddenboxes. A box holds the target where its containing blocks pass through it, as CSS has it: an absolutely positioned element leaves the boxes between it and its containing block. Each box's offset is worked out from where the target will be once the boxes inside have moved, so all scroll at once, and the document'sscroll-behavior: smoothanimates them. A surface not placed scrolls nothing; one out of view is laid out unseen for it, asr=automeasures, and keeps the offsets. -
View (SPEC §9.5). The elements with
viewindata-onand an id are in view where some of their border box, asgetBoundingClientRecthas it (transforms included), lies inside the window, within the viewport's scrollbars, and inside the scrollport of each box that holds and clips it (overflow other thanvisible, or paint containment), by more than half a device pixel. An element thinner than that, an empty one, is in view where it lies inside, as a cell's top edge is the cell's. Onedisplay: none,visibility: hidden, or inside a closed<details>is not (checkVisibility).endis the root'sscrollTopwithin a pixel of its end. When:- A check waits for the next frame, so a frame sends one
viewat most, and it goes out only when what it carries differs from what the program heard last. Each of these asks for one: a placement, a document, a delta, a resource, the host stylesheet, an image, a stylesheet or a font loading, a<details>toggled, a transition or an animation ending, the root, the body or an element that reports changing size (ResizeObserver), and an element that reports coming into the window or leaving it (IntersectionObserver, with the window as its margins). - A scroll holds checks until every box it moves has come to rest: its
scrollend, or, where the browser has none, 150 ms without a scroll (600 ms stands for ascrollendthat never came). A gesture's momentum and a smooth scroll send oneview, where they end. - Each
a=placeis a new placement, whose firstviewgoes out once it is drawn, whatever the program heard before;a=hidestops them, and a placement out of view checks once it is shown again. A new document sends one once laid out. A detached surface sends none. - Two elements with one id report as one: the first in view.
- A check waits for the next frame, so a frame sends one
-
Cursor. After
a=placethe cursor moves below the surface, as in the native host. xterm.js has no public API for that, so the addon uses the same private calls as the official image addon. -
What xterm.js sends for a key a text field names (SPEC §10.4) is read from its core service's
triggerDataEvent, also private, which the addon holds back while the key goes through xterm.js's keyboard handling. -
Cell size comes from xterm's render service (also private), with a measured fallback.
-
xterm.js 6.1 is needed for the kitty keyboard protocol (
vtExtensions.kittyKeyboard); the addon itself works with 6.0.
Security
Program markup is untrusted: it may come from cat, or from a server on the
far side of SSH. Two independent layers stand between it and the page, and a
test checks each one (tests/e2e/hostile.spec.ts):
- The sanitizer (
resolver.ts) works on inert parses. It removesscript,iframe,object,embed,base,metaand anylinkthat is not a stylesheet. It also removeson*,srcdoc,autofocusandping. URL attributes resolve toblob:forcid:, stay as they are fordata:, become absolute URLs where the network policy allows them (the page's grant and the document's request, both), and fail closed asabout:invalidfor everything else. A document's<base>and<meta name="hotty-network">are read before they are removed. - The iframe:
sandbox="allow-same-origin allow-forms": no scripts, popups or navigation.allow-formsis there only so thatsubmitfires; the submission is cancelled.- A CSP
<meta>written by the parser before any program markup:default-src 'none', withdata:andblob:allowed for images, media and fonts, and the page'snetworkgrant; inline styles allowed, andform-action 'none'. It holds the page's grant on its own: a reference the sanitizer misses reaches nothing the page did not grant. Network URLs insidecid:stylesheets are not fetched (a stylesheet resource serves surfaces with different policies); a<link>to the stylesheet on the network, or a<style>, is.
With the CSP removed, 9 requests get through, all from CSS (@import, url()
and @font-face). With the sanitizer passing URLs through, the CSP alone
stops everything. position: fixed stays inside the surface's own viewport.
The embedding page's CSP applies inside the surfaces too (about:blank
iframes inherit it, and it can only be narrowed). A page that embeds xterm.js
already has to allow inline styles, because xterm's DOM renderer sets them.
For hotty it must also allow data: and blob: images, fonts and media, and
whatever origins it grants in network. This is the recommended policy (with
no network grant), and tests/e2e/embedder.spec.ts checks it:
default-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self' data: blob:; media-src 'self' data: blob:
Differences from hotty-blitz
- Pixels differ. The browser renders, so text, form controls and antialiasing look like the browser's. The cell footprint is the same (§9).
- Zoom and fonts. A zoom or a change of display scale updates
--hotty-pxin the host stylesheet (SPEC §8) and sends nothing; a font change that moves the cell sendscapswith the newcellcssto programs that asked for caps. The browser also gives native<select>, IME and spellcheck. - Not here yet:
- §9's text in xterm's buffer (search, serialize, and a selection across a surface);
- following content that scrolls on the alternate screen;
- the embedder's web fonts inside surfaces (system fonts work);
hover(§9.4,v=1ona=place):featuresdoes not list it, so a program never asks, and the vectors that require it are skipped. The frame would report the nearest id onpointeroverwith no button down, and out on leaving the frame, an Alt press, or a release outside it.
Costs
Measured 2026-09-29 in headless Chromium 153 (bench/):
- A surface takes 4.7 ms to create and place, 8.7 ms of main-thread time with its first frame, and 1.2 MB of memory.
- The dashboard costs 4.9 ms of main-thread time per frame at 10 Hz.
- Large documents cost Chrome more per frame as they grow: its PrePaint walks the tree. A one-cell delta costs 8.4 ms of main-thread time per frame at 4,096 cells and 46 ms at 262,144. CSS containment brings the latter to 30 ms; hotty-blitz does it in about 0.1 ms.
- Bub-n-Bros (
examples/bubbros.py) costs about 4 ms per frame: deltas to seven sprites out of about 380.
Tests
npm run check runs:
- the typecheck, the build and the declarations (what
npm packships); - the unit tests (
node --test: the wire, keys, the keys any focused element gives the program, decoding the reference Python client, the msgpack bodies, as the reference client reads them, the tests' reader against the SDKs' decode vectors, and the conformance vectors' wire, keys, keymap and edit sections); - the Playwright tests, against Chromium (and Firefox once installed:
npx playwright install firefox):- the protocol and the conformance vectors. Every body the addon sends
is read as an SDK reads it, so a nil, a key that is not a string or is
given twice in one map, or an int past 2^53 − 1 anywhere fails the test
(SDK.md §3.9), and with its fields' types (
tests/msgpack.ts); the vectors' numbers keep theirs:720.0is a float,720an int; - interaction, with
form.pythrough the bridge; - text fields' keymaps beyond the vectors (
fields.spec.ts); - the hostile page and the embedder's CSP;
- Bub-n-Bros with kitty keys (skipped unless the game is fetched).
- the protocol and the conformance vectors. Every body the addon sends
is read as an SDK reads it, so a nil, a key that is not a string or is
given twice in one map, or an int past 2^53 − 1 anywhere fails the test
(SDK.md §3.9), and with its fields' types (
bench/measure.mjs and bench/trace.mjs produce the cost numbers.
Licence
Apache-2.0 (LICENSE). xterm.js itself is MIT.