No description
  • TypeScript 55.3%
  • Rust 23.1%
  • HTML 10%
  • JavaScript 7.5%
  • Nix 2.9%
  • Other 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium 181870a181
Some checks failed
ci / fmt / clippy / test / deny / types / bundle (push) Failing after 40s
feat(web): re-snapshot only the rows that changed since the last render
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
2026-08-23 14:21:29 +09:00
.forgejo/workflows chore: rename to felis-web-component 2026-08-09 11:52:48 +09:00
bench test(bench): add the xterm.js comparison harness the docs refer to 2026-08-23 13:58:33 +09:00
docs chore: rename to felis-web-component 2026-08-09 11:52:48 +09:00
nix fix(nix): ship felis-glyphs with the bundle, as package.json says it does 2026-08-22 14:15:06 +09:00
src feat(web): re-snapshot only the rows that changed since the last render 2026-08-23 14:21:29 +09:00
tests feat(web): re-snapshot only the rows that changed since the last render 2026-08-23 14:21:29 +09:00
web feat(web): re-snapshot only the rows that changed since the last render 2026-08-23 14:21:29 +09:00
.gitignore feat(web): draw the glyphs a font sizes to its own em box 2026-08-15 20:08:02 +09:00
Cargo.lock chore: rename to felis-web-component 2026-08-09 11:52:48 +09:00
Cargo.toml chore: rename to felis-web-component 2026-08-09 11:52:48 +09:00
clippy.toml chore(web): adopt felis core's lint and supply-chain config 2026-06-21 10:26:56 +09:00
deny.toml chore(web): adopt felis core's lint and supply-chain config 2026-06-21 10:26:56 +09:00
flake.lock chore: lock the gateway input the flake already declares 2026-08-15 20:06:19 +09:00
flake.nix chore: rename to felis-web-component 2026-08-09 11:52:48 +09:00
LICENSE chore(web): declare the MSRV and ship the Apache-2.0 LICENSE file 2026-06-21 22:50:29 +09:00
README.md feat(web): draw the glyphs a font sizes to its own em box 2026-08-15 20:08:02 +09:00
rustfmt.toml chore(web): adopt felis core's lint and supply-chain config 2026-06-21 10:26:56 +09:00
tsconfig.json feat: ship the web layer as an installable package, not just a store path 2026-08-07 15:14:08 +09:00

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-core builds for wasm32-unknown-unknown (its native feature gates the tokio/transport surface).
  • FelisView wasm wrapper: new(rows, cols), applyJson(msg), rows/cols/title getters. 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 one FelisConnection; only its transport (a FrameSource) differs: a .fcast replayed 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 negotiates SCROLL_OP | ROW_DELTA_BATCH | SEARCH | CLI_OPS | NOTIFY | GRID_SIZE and reports the daemon-intersected set in its attach header, which is what unlocks the search / capture / notify / resize surface below. (PULL_PACING is 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, and caps() so a host can hide what its daemon can't serve. Each rides an existing InputMsg variant, 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 (a ResizeObserver plus visualViewport, because the soft keyboard shrinks the visual viewport while leaving the layout one alone) and reports one InputMsg::Resize per 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, and sendText("git status\n") sends raw bytes rather than a bracketed paste (which would swallow the newline). web/mobile.html is 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 the wheel handler uses — one shared function owns the native client's wheel_encoding_for truth table, so mouse-tracking programs get wheel buttons, alt-screen pagers get arrow keys and a primary screen drives InputMsg::Viewport, whoever asked. mode() reports { mouseActive, altScreen } so an app can label a gesture. A phone produces no wheel and no drag mousemove, 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 (see docs/mobile.md).
  • Go to a search hit: revealLine(lineIndex, { position }) brings the line a SearchMatch named 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 a scroll() delta. The mapping is the daemon's own composition (line i paints at row i + 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)×s cell box.
  • Kitty graphics: ImageMsg frames drive a wasm ImageShadow; 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): themeJson surfaces 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 (and createTerminal({ 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 (and createTerminal({ fontSize }) / setFontSize()), clamped to 832 with the line height following. Under resize="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 bottom recording (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 Scrolled directive evicts off the top (alt-screen and sub-region scrolls excluded). This needs the recorder to negotiate SCROLL_OP (a plain RowDelta overwrite carries no "this scrolled off" signal); felis-fcast records with it. A live session needs no ring: the daemon owns the real scrollback, so the wheel sends InputMsg::Viewport and the daemon re-ships the scrolled rows plus a ViewportState the view mirrors (viewport/viewportMax) — verified against a live daemon with SCROLL_OP negotiated. A live view therefore sets FelisView::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, checks Origin against --origin when a browser sends one, and speaks wss:// directly given --tls-cert/--tls-key (so "phone on a VPN, daemon at home" needs one process, not a reverse proxy). The token rides the Sec-WebSocket-Protocol offer rather than the URL, because a WebSocket constructor cannot set headers and a query parameter would put the credential in access logs and history — see broker.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.md is 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 ch units, 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.ready before 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+2500U+257F), Block Elements (U+2580U+259F), Braille Patterns (U+2800U+28FF) and the Powerline separators (U+E0B0U+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 btop graph 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

832 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).