- TypeScript 55.5%
- Rust 22.3%
- HTML 10.3%
- JavaScript 7.6%
- Nix 2.9%
- Other 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
felis-cast now records the recorder's base theme as a meta.theme hint, so a shared recording can look the way it looked when it was made. Without reading it the player painted every recording in its built-in default, including output a program had tuned to the recorder's background through OSC 11 ; ?. The hint applies only while the host has chosen no theme, and a host choice replaces it whole rather than slot by slot, so a partial host theme cannot pair with the hint's other colours into a combination neither side picked. setTheme(null) and removing the theme attribute now withdraw the host choice and return to the hint; before, an unknown or removed theme fell back to the built-in default directly. The hint's palette is not applied because the palette here is fixed client policy. Also drop the comment pointing at `felis-cast migrate`, a command that does not ship: a recording under the earlier format id is re-recorded. Assisted-by: Claude Code |
||
| .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 ShadowScreen verbatim. JS only feeds in already-decoded
frames and paints the resulting grid.
.fcast (NDJSON) ──► felis-terminal ──► applyJson(msg) ──► ShadowScreen (wasm) ──► renderer
(felis-json v1 → GridMsg) (felis-client-core)
A frame's msg is a felis-json v1 payload — the named JSON form of a
daemon frame that felis publishes (felis_grid::json_v1, schema
crates/felis-grid/schemas/felis-json-v1.schema.json). The wasm core
reads it through felis's own DTOs, which also repack each row into the
row codec the shadow screen decodes, so this repo forks no shape. The
.fcast envelope (NDJSON header + [t, kind, msg] frames) is owned by
felis-cast and specified in its
docs/reference/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. Its attach reply carries the connection's effective protocol minor, which the view exposes asprotocolMinor(); felis has no capability bitmap to intersect, so a browser feature that needs a wire addition gates on that number. - ✅ Attention and prompt navigation on the public
Terminal:onNotify()for the session's bells and OSC 9/99/777 notifications (felis carries both on oneAttentionvariant) andjumpPrompt()for OSC 133 marks — both ride the per-stream path the broker routes. - ⚠️ Search and scrollback export are unreachable from a browser.
They left
InputMsgwhen felis split the wire into per-family correlated conversations: they areSearch/Regionrequests whose request ids the connection driver allocates, and the gateway's broker routes neither family (felis-web-gatewaydocs/broker.md, "Stream frames").search(),cancelSearch()andcaptureText()for anything but"visible"therefore refuse locally rather than writing a frame the daemon would answer by ending the connection.captureText("visible")is unaffected — it reads the shadow screen this side of the wire. - ✅ 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 })names the key through the same path a physical keystroke takes (the daemon encodes it against the modes it owns), 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. - ✅ Recorded theme hint: a
.fcastwhose header carriesmeta.theme(the recorder's base theme) paints with its foreground, background and cursor while the host has picked no theme; athemeattribute orcreateTerminal({ theme })replaces the hint whole, andsetTheme(null)(or removing the attribute) returns to it. The hint'spaletteis not applied, since the palette here is client policy. - ✅ 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). It needs the recording to carryScrolleddirectives at all — a plainRowDeltaoverwrite carries no "this scrolled off" signal — which is the daemon's emission policy, not something a peer negotiates any more. 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). 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
pinned to a release tag of
github.com/felis-terminal/felis
(the felis crates are not on crates.io). No sibling ../felis checkout
is needed. To hack on felis and felis-web-component together, add a
[patch."https://github.com/felis-terminal/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-cast. To regenerate the demo recordings under
web/public/, record there and point --out back here:
cd ../felis-fcast && nix develop
cargo run -p felis-cast -- record \
--socket /path/to/daemon.sock --out ../felis-web-component/web/public/session.fcast \
--command 'printf "hello\n"; uname -sr'
See felis-cast's README for the full recorder usage (idle/max caps,
capturing animations like the demo page's tte.fcast).