- Rust 51.2%
- TypeScript 36.8%
- Nix 7.1%
- HTML 3.7%
- JavaScript 1.2%
The replay apply loop only branches Grid/Image, which looked like an unhandled case. Control frames are session/protocol metadata (Hello, Welcome, Ping, Detach…) with no view method to apply, and the attach handshake is consumed before a .fcast records — so dropping them is correct. Say so, so the omission reads as intentional. The recorder was renamed felis-recorder -> felis-fcast in the sibling repo; refresh the demo recordings' meta.recorder label to match the tool that (re-)produces them. Metadata only — players do not interpret meta — so the frames are untouched. Assisted-by: Claude Code Opus 4.8 |
||
|---|---|---|
| gateway | ||
| nix | ||
| src | ||
| tests | ||
| web | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| tsconfig.json | ||
felis-web
An embeddable wasm/web client for felis — it replays a
recorded session (.fcast) through the real felis rendering path, so
documentation can embed a live component instead of a screenshot. The
near-term host is a Tauri-style local webview; the same component is meant
to drop into a plain browser later.
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 (Tauri now, browser later) is a thin, swappable frame source.
We do not reimplement the terminal state machine in JS — felis-web
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 one.
Staging: replay first, then pseudo-input (a future wasm felis-vt +
felis-grid fed canned bytes). Recording and replay stay downstream — the
recorder and the .fcast format live in felis-fcast; a
recorder is a client-core attach that tees frames, not a felis-core
feature.
Status
- ✅
felis-client-corebuilds forwasm32-unknown-unknown(felis gained anativefeature gating its 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 — modelled as "talking to a pseudo-daemon", so the view can't tell a recording from a live session. - ✅ Live session via
felis-web-gateway(gateway/): a native client-core attach that re-exposes one daemon session over a WebSocket so the browser can run a live, bidirectional session. Built as a separate workspace member so the wasmcargo buildstays free of its tokio dependency. - ✅ 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. - ↗️ The
.fcastrecorder and envelope spec now live infelis-fcast(FORMAT.md+felis-recorder); this repo consumes the format, it no longer defines or produces it. - ✅ 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). Stills + cell-anchored placements today; animation frames and Unicode-placeholder virtual placements 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). - ✅ 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 — the 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 (braille, some box-drawing) can't drift the grid, and 0xProto Nerd Font Mono is bundled (web/fonts/, OFL base + Nerd Fonts icons) so text, box-drawing and Nerd Font icons render identically on every host instead of leaning on the system monospace. - ✅ 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 — sofelis-fcastnow records 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). (Images-in-scrollback deferred: placements are cell-anchored to the live grid, so the wheel hides them.) - ⏭️ Next: a Tauri host. (Kitty graphics animation + virtual placements, and images-in-scrollback, 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 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.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 are gitignored
build artifacts; web/felis-terminal.ts is the source.
Record a .fcast
The recorder and the .fcast format moved to
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/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).