- TypeScript 55.3%
- Rust 23.1%
- HTML 10%
- JavaScript 7.5%
- Nix 2.9%
- Other 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
ci / fmt / clippy / test / deny / types / bundle (push) Failing after 40s
Every render batch read back the whole grid from wasm and re-parsed it, even when nothing but the cursor had moved — an idle session paid full grid traffic per frame forever. FelisView now marks damage from the applied message shape (a RowDelta marks its row; a scroll or resize marks the screen) for free, instead of comparing cells after the fact. The renderer drains that set once per paint: null means reuse the cached screen and refresh only the cursor, a row list fetches just those rows via snapshotRowsJson and merges them into the cache, and a resize or rehydrate still takes one full snapshot to reshape it. The partial reply is pinned by test against the same rows in the full snapshot — the renderer merges the two paths into one screen, so any divergence would paint different terminals depending on which path ran. Scrolling workloads see no change (every row moves), but cursor-only and sparse-update frames — most of an idle session's life — now skip the cell traffic entirely. Assisted-by: Pi Coding Agent:Ox Alpha Free |
||
| .forgejo/workflows | ||
| bench | ||
| docs | ||
| nix | ||
| src | ||
| tests | ||
| web | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| clippy.toml | ||
| deny.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| rustfmt.toml | ||
| tsconfig.json | ||
felis-web-component
An embeddable wasm/web client for felis. It runs a live
session through a gateway, and replays a recorded one (.fcast) through
the same rendering path — so documentation can embed a real terminal
instead of a screenshot. The target host is a plain browser, and the
focus is mobile: a phone that keeps its sessions across a lost radio
(docs/mobile.md).
Architecture (A): reuse the Rust core, render in the webview
The renderer and grid logic live on the webview side and are portable;
the host is a thin, swappable frame source.
felis-web-component does not reimplement the terminal state machine in JS: it
compiles felis's felis-client-core to wasm (default-features = false,
which drops its tokio/transport native surface) and reuses its
transport-free ShadowGrid verbatim. JS only feeds in already-decoded
frames and paints the resulting grid.
.fcast (NDJSON) ──► felis-terminal ──► applyJson(msg) ──► ShadowGrid (wasm) ──► renderer
(serde_json → GridMsg) (felis-client-core)
A frame's msg is the verbatim serde_json of a felis-protocol message;
decoding goes through serde_json (not serde-wasm-bindgen) so it is
byte-identical to how felis.el and the daemon's strict decoder read the
same bytes. The wire-message schema is owned by felis-protocol; the
.fcast envelope (NDJSON header + [t, kind, msg] frames) is owned by
felis-fcast and specified in its
FORMAT.md. This repo consumes a .fcast;
it does not define, record, or produce one (a recorder is a client-core
attach that tees frames, not a felis-core feature).
Staging: replay first, then pseudo-input (a future wasm felis-vt +
felis-grid fed canned bytes).
Status
- ✅
felis-client-corebuilds forwasm32-unknown-unknown(itsnativefeature gates the tokio/transport surface). - ✅
FelisViewwasm wrapper:new(rows, cols),applyJson(msg),rows/cols/titlegetters. Runtime-verified in node (tests/apply.rs). - ✅ TypeScript web layer (
web/felis-terminal.ts) with a functional, xterm.js-shaped API:createTerminal()(a view, transport-blind) and a thin<felis-terminal>custom element over it. There is oneFelisConnection; only its transport (aFrameSource) differs: a.fcastreplayed by a clock, or a live WebSocket. Either way the view talks to a pseudo-daemon, so it can't tell a recording from a live session. - ✅ Live session via
felis-web-gateway: a native client-core attach that re-exposes one daemon session over a WebSocket so the browser can run a live, bidirectional session. It lives in its own repo, because it shares no code with the wasm core — only the wire contract — and a native tokio binary in a workspace whose one crate targets wasm cost more than it bought. It negotiatesSCROLL_OP | ROW_DELTA_BATCH | SEARCH | CLI_OPS | NOTIFY | GRID_SIZEand reports the daemon-intersected set in its attach header, which is what unlocks the search / capture / notify / resize surface below. (PULL_PACINGis withheld: the daemon would then wait for a per-vsync frame request the browser doesn't send.) - ✅ Search, plain-text export and notifications on the public
Terminal:search()/cancelSearch()over daemon-side scrollback,captureText("visible" | "scrollback" | "command_output" | "last_command"),onNotify()for OSC 9/99/777,jumpPrompt()for OSC 133 marks, andcaps()so a host can hide what its daemon can't serve. Each rides an existingInputMsgvariant, so the reply frames the shadow grid ignores by design are routed to their subscribers instead. - ✅ Opt-in dynamic geometry:
resize="fit"measures the cells that fit the visible box (aResizeObserverplusvisualViewport, because the soft keyboard shrinks the visual viewport while leaving the layout one alone) and reports oneInputMsg::Resizeper 150 ms settle. The default stays attach-pinned, so a documentation embed keeps its footprint. - ✅ Input API for a host's shortcut bar:
sendKey({ key: "c", ctrl: true })encodes through the same path a physical keystroke takes, andsendText("git status\n")sends raw bytes rather than a bracketed paste (which would swallow the newline).web/mobile.htmlis a demo app layer over these — fit-sized terminal, shortcut bar, export, search. - ✅ Scroll API for a touch model:
scroll(ticks)applies wheel ticks (positive = towards the newest output) through the same routing thewheelhandler uses — one shared function owns the native client'swheel_encoding_fortruth table, so mouse-tracking programs get wheel buttons, alt-screen pagers get arrow keys and a primary screen drivesInputMsg::Viewport, whoever asked.mode()reports{ mouseActive, altScreen }so an app can label a gesture. A phone produces nowheeland no dragmousemove, so without these a terminal on a phone could not be scrolled at all; a raw viewport setter would instead have made every app re-derive the truth table. The component still registers no touch or pointer handlers — the recognizer is being proven in an app first (seedocs/mobile.md). - ✅ Go to a search hit:
revealLine(lineIndex, { position })brings the line aSearchMatchnamed into view, centred by default ("top"/"bottom"place it at an edge). Without it a result list could only copy a line — the absolute viewport offset is not exposed, so no app could turn a match into ascroll()delta. The mapping is the daemon's own composition (lineipaints at rowi + viewport), shared with the wheel arm's clamp and in-flight bookkeeping, so a reveal composes with a scroll rather than collapsing into it. A live-screen hit snaps to the live bottom instead of centring, and the alt screen — where the daemon pins the viewport to 0 — is a documented no-op. - ✅ Renders selectable, copyable DOM text (not a canvas): rows of
coalesced attr-run
<span>s, with a copy handler that trims trailing whitespace per line. - ✅ Kitty text-sizing (OSC 66): the snapshot surfaces per-cell
sz, and the renderer scales sized glyphs over their(w·s)×scell box. - ✅ Kitty graphics:
ImageMsgframes drive a wasmImageShadow; the renderer composites each placement as a positioned image (z-ordered behind/above the text). Covers stills, cell-anchored and Unicode-placeholder (U=1) placements, and source-rect crops (x=,y=,w=,h=); animation frames are TODO. - ✅ OSC 8 hyperlinks: linked cells surface their URI as
lk; the renderer wraps link runs in a clickable<a>(scheme-restricted, the daemon's REQ-910 check re-verified client-side). - ✅ Daemon theme (OSC 10/11/12):
themeJsonsurfaces the fg/bg/cursor overrides; the renderer uses them as its themed defaults (the 16-/256-colour palette stays client policy). - ✅ Host-selectable theme: a
theme="<name>"attribute (andcreateTerminal({ theme })) picks the fg/bg/cursor defaults from a built-in preset table (THEMES: tokyo-night, solarized dark/light, gruvbox-dark, dracula, nord); the demo gallery wires it to a live dropdown. A recording's own OSC 10/11/12 still wins per render, so a program that paints its own colours overrides the picked theme rather than the reverse. Swapping the attribute recolours in place; it does not restart the replay. - ✅ Host-selectable cell size: a
font-size="<px>"attribute (andcreateTerminal({ fontSize })/setFontSize()), clamped to 8–32 with the line height following. Underresize="fit"this is what decides how many rows and columns the session gets, which is why it is the component's business rather than the page's CSS: the cell is an input to a geometry the daemon has to be told about. Changing it resizes in place — the terminal is not recreated, so a live session is not detached. - ✅ Cursor style/blink: the snapshot carries
style/blink; the renderer draws Block/Bar/Underline and a CSS blink animation. - ✅ TUI demo: a live
bottomrecording (box-drawing, braille graphs, truecolor) replays cleanly through the same grid: proof that a CLI/TUI tool can be published as a real, selectable terminal rather than a screencast. Each row run is pinned to its exact column width so a glyph whose advance ≠ one cell can't drift the grid, and the ranges a TUI is actually built from — box drawing, blocks, braille, Powerline — are drawn to the cell by the renderer rather than taken from the face, so frames join and graphs appear whatever the host has installed. See Drawn, not typed and The font. - ✅ Replay scrollback: output that overflows the grid scrolls into a
client-side history ring and the mouse wheel browses it. felis owns
scrollback on the daemon, gone by replay time, so the client rebuilds
its own: it tees each row a full-screen
Scrolleddirective evicts off the top (alt-screen and sub-region scrolls excluded). This needs the recorder to negotiateSCROLL_OP(a plainRowDeltaoverwrite carries no "this scrolled off" signal);felis-fcastrecords with it. A live session needs no ring: the daemon owns the real scrollback, so the wheel sendsInputMsg::Viewportand the daemon re-ships the scrolled rows plus aViewportStatethe view mirrors (viewport/viewportMax) — verified against a live daemon withSCROLL_OPnegotiated. A live view therefore setsFelisView::retainScrollback = false, so the ring costs nothing where it would only duplicate the daemon. Image placements shift with the viewport and clip to the visible grid, so they browse with the text. - ✅ Off-host use: the gateway refuses to serve a non-loopback address
without
--token/--token-file, checksOriginagainst--originwhen a browser sends one, and speakswss://directly given--tls-cert/--tls-key(so "phone on a VPN, daemon at home" needs one process, not a reverse proxy). The token rides theSec-WebSocket-Protocoloffer rather than the URL, because aWebSocketconstructor cannot set headers and a query parameter would put the credential in access logs and history — seebroker.md. - ✅ Keepalive:
--ping-interval(25 s, 0 disables) on both relays. A client that vanishes without a FIN is reaped after two unanswered pings, so its sessions stop reading as attached on the daemon. A browser pongs below JavaScript, so the page needs nothing — and the page noticing sooner is not what this is for (a backgrounded tab has throttled timers; that is the on-resume probe's job). - ⏭️ Next: the mobile roadmap in
docs/mobile.mdis closed; what is left is the app layer on top — an explicit touch model (scroll / select / mouse-report) rather than gesture guessing. (Kitty graphics animation deferred.)
Develop
The flake is the single source of truth for the toolchain (nightly Rust +
wasm32-unknown-unknown, wasm-bindgen-cli, node, and nixpkgs lld for
the wasm link). It depends on felis via git dependencies
(git.natsukium.com/natsukium/felis); Cargo.lock pins the exact commit
and cargo update follows felis main. No sibling ../felis checkout is
needed. To hack on felis and felis-web-component together, add a
[patch."https://git.natsukium.com/natsukium/felis"] with local path
overrides.
nix develop
cargo test --target wasm32-unknown-unknown # runs the wasm tests in node
Build the web component
# 1. wasm core (emits web/pkg/, including the .d.ts tsgo type-checks against)
cargo build --lib --target wasm32-unknown-unknown --release
wasm-bindgen target/wasm32-unknown-unknown/release/felis_web_component.wasm \
--out-dir web/pkg --target web
# 2. TypeScript → web/felis-terminal.js (emitted in place, no bundler)
tsgo
node web/serve.mjs web 8080 # then open http://localhost:8080
Both web/pkg/ and the compiled web/felis-terminal.js (plus its
emitted .d.ts) are gitignored build artifacts; web/felis-terminal.ts
is the source.
Use it from an app
The web layer publishes as @felis-web/component (219 kB). It currently
lives in this Forgejo instance's npm registry, so a consumer points the
scope at it once:
# .npmrc, in the consuming project
@felis-web:registry=https://git.natsukium.com/api/packages/natsukium/npm/
npm install @felis-web/component
import "@felis-web/component"; // defines <felis-terminal>
import { connectBroker } from "@felis-web/component/broker";
const broker = await connectBroker("wss://felis.example:8771", { token });
document.querySelector("felis-terminal").source = broker.attach(sessionId);
The package resolves its own wasm core relative to import.meta.url, so
it works under a bundler and under a plain node_modules static serve
alike — a host page never has to know where it is.
The font
<felis-terminal> ships no font, and nothing here distributes one.
Which face to render with is not the terminal's call — everybody wants a
different one, and an app with a font pipeline already has a way to load
it. Three megabytes of woff2 inside the component would be three
megabytes nobody could decline.
So: load your face however you already load faces, and name it.
<felis-terminal src="wss://…" font-family="'Berkeley Mono', monospace"></felis-terminal>
createTerminal({ fontFamily: "'Berkeley Mono', monospace" });
Two constraints, and they are the only ones:
- It must be monospace. Every cell is positioned in
chunits, so a proportional face would shear the grid. It does not have to cover much past text: box drawing, blocks, braille and the Powerline separators are drawn by the renderer rather than taken from the face — see Drawn, not typed. - Start the load early. The component waits on
document.fonts.readybefore its first paint, so a load already in flight is measured against; one started later swaps in with a reflow.
Swapping the face on a live terminal happens in place, like the size below — for the same reason, and a sharper one: an app offering a font picker is changing it on a session somebody is reading.
term.setFontFamily("'Berkeley Mono', monospace"); // or the attribute
A value the browser cannot parse falls back to the default stack rather
than reaching the sheet. The cell is a font shorthand assembled from
custom properties, and one invalid substitution invalidates that whole
declaration — which would drop the grid to the page's inherited,
proportional face instead of merely ignoring the name.
Drawn, not typed
Box Drawing (U+2500–U+257F), Block Elements (U+2580–U+259F), Braille
Patterns (U+2800–U+28FF) and the Powerline separators (U+E0B0–U+E0BF)
are synthesized by web/felis-glyphs.ts onto a canvas under the text,
in device pixels, each cell edge rounded once and shared with its
neighbour so adjacent cells meet exactly.
Two reasons, the same two felis's native renderer gives in
crates/felis-render-wgpu/src/box_drawing.rs — this is a port of that
module's tables and geometry, so the two clients agree about what a
session looks like:
- A font sizes these to its own em box, not to the cell. 0xProto's
█is 1.213 em tall against a 1.333 em row, so stacked blocks leave a seam and a TUI's│frames come out dotted. - No common font ships braille or Powerline at all. A phone's system
monospace draws blank cells where a
btopgraph should be, and tofu where every prompt framework puts its separators. Synthesizing them is what makes a patched font optional instead of required.
The character itself stays in the DOM — transparent, with its background
moved to the canvas underneath — so selection, copy and the link rules
cannot tell which glyphs the font supplied. ╱╲╳ and the Symbols for
Legacy Computing mosaics (sextants, octants) are not covered yet and
fall through to the face, which is what any unlisted code point does.
The size
The face's size is the component's, though, and deliberately not the page's CSS:
<felis-terminal src="wss://…" font-size="12"></felis-terminal>
createTerminal({ fontSize: 12 });
term.setFontSize(term.fontSize() - 1); // on a live terminal
8–32 px, clamped rather than refused, with the line height following at
the default pair's ratio — so this is the whole cell, and under
resize="fit" the whole geometry: 15 px on a 390 px phone is about 43
columns, and 11 px is about 59. That is the difference between ls -l
wrapping every line and not, which is why the size cannot be left to a
stylesheet: a cell the component did not choose is a row and column count
the daemon was never told about.
Changing it on a live terminal repaints and re-fits in place. The element is not re-run, because an app that could only resize by recreating the element would detach the session it is showing — and on a phone the control that changes this is a button somebody is holding down.
Set nothing and you get the default chain, "0xProto Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace. Only
the tail of that is a promise — the head is a hint, so a machine that
happens to have 0xProto installed uses it, and one that does not renders
correctly on the system monospace. What that costs is now only the Nerd
Font icons: the frames, graphs and prompt separators a TUI draws come
from the renderer either way.
web/fonts/ in this repo is a test and demo fixture, not a package:
the demos and the browser tests render against a pinned face so
screenshots and glyph-width assertions mean the same thing on every
machine. web/fonts/index.js is what loading a face looks like, if you
want a worked example — copy it, don't depend on it.
web/mobile.html is not in the package either — it is the by-hand
exercise rig for the whole surface (tabs, host picker, shortcut bar, IME,
clipboard), and the shape an app on top would take rather than something
to import.
Publishing
nix build .#web produces the published tree directly, so a release is
one artifact rather than a second build of the same wasm. web/package.json's
files list is what leaves the demo HTML, public/ and the fixture font
behind:
nix build .#web
cp -rL result dist && chmod -R u+w dist
npm publish ./dist
CI does this on a v* tag (.forgejo/workflows/release.yml) and refuses
a tag that disagrees with web/package.json's version — an npm version
is immutable, so the check has to happen before the registry takes it.
Keep that version and the crate versions in Cargo.toml in step.
Plain npmjs.com is the eventual home; the @felis-web scope is
registered there, so that move is a registry change and not a rename.
One-command live demo
The demo app is the live counterpart to the .fcast gallery: it boots
a throwaway felis-daemon, the WebSocket gateway, and a static server, then
opens a browser at a real, bidirectional shell on this machine. Each
browser tab spawns its own fresh login shell (the gateway runs with no
--session).
nix run .#demo # self-contained: bundles daemon + gateway + node
nix run bakes felis-daemon, the gateway (a flake input — the binary is
built from
felis-web-gateway)
and the built web bundle onto PATH, so it needs nothing pre-installed.
From a dev checkout you can run the orchestrator directly, but then both
servers have to come from somewhere: felis-daemon from PATH or
$FELIS_DAEMON, the gateway from PATH or $FELIS_WEB_GATEWAY. This
checkout cannot build one for you.
node web/demo.mjs # needs the web component built (above) + both servers on PATH
node web/demo.mjs --port 8080 --ws-port 8771 --no-open
Ctrl-C tears down all three children and removes the scratch socket dir.
This is a local demo only: the gateway binds 127.0.0.1 and the daemon
socket lives in a private temp dir, so nothing is exposed off-host.
Serve it off-host
Serving is the gateway's job and its flags are documented where they are
implemented — auth, Origin, in-process TLS and the roster all live in
felis-web-gateway's README.
Duplicating them here is how the two would drift.
What is this side's business is where the token goes.
web/mobile.html takes it from the URL fragment
(…/mobile.html#token=<secret>), which is never sent to a server, then
moves it into sessionStorage and clears the address bar. The
one-session embed takes it as an attribute:
<felis-terminal src="wss://…" token="…">.
Record a .fcast
The recorder and the .fcast format live in
felis-fcast. To regenerate the demo recordings under
web/public/, record there and point --out back here:
cd ../felis-fcast && nix develop
cargo run -p felis-recorder -- \
--socket /path/to/daemon.sock --out ../felis-web-component/web/public/session.fcast \
--command 'printf "hello\n"; uname -sr'
See felis-fcast's README for the full recorder usage (idle/max caps,
capturing animations like the demo page's tte.fcast).