No description
  • Rust 51.2%
  • TypeScript 36.8%
  • Nix 7.1%
  • HTML 3.7%
  • JavaScript 1.2%
Find a file
natsukium 37bde0e043
chore(web): document Control-frame drop, refresh demo recorder label
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
2026-06-21 00:23:22 +09:00
gateway feat(web): scroll back through replay and live history 2026-06-20 23:40:25 +09:00
nix feat: expose the web bundle as a Nix package 2026-06-20 20:55:54 +09:00
src feat(web): scroll back through replay and live history 2026-06-20 23:40:25 +09:00
tests feat(web): scroll back through replay and live history 2026-06-20 23:40:25 +09:00
web chore(web): document Control-frame drop, refresh demo recorder label 2026-06-21 00:23:22 +09:00
.gitignore refactor(web): TypeScript + functional API + connection abstraction 2026-06-19 23:12:35 +09:00
Cargo.lock refactor: extract .fcast tooling to felis-fcast, use git deps 2026-06-20 18:27:52 +09:00
Cargo.toml refactor: extract .fcast tooling to felis-fcast, use git deps 2026-06-20 18:27:52 +09:00
flake.lock feat: wasm ShadowGrid replay wrapper (Architecture A) 2026-06-19 15:09:57 +09:00
flake.nix feat: expose the web bundle as a Nix package 2026-06-20 20:55:54 +09:00
README.md feat(web): scroll back through replay and live history 2026-06-20 23:40:25 +09:00
tsconfig.json refactor(web): TypeScript + functional API + connection abstraction 2026-06-19 23:12:35 +09:00

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-core builds for wasm32-unknown-unknown (felis gained a native feature gating its 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 — 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 wasm cargo build stays 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 .fcast recorder and envelope spec now live in felis-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)×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). 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): themeJson surfaces 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 bottom recording (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 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 — so felis-fcast now 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). (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).