No description
  • TypeScript 55.5%
  • Rust 22.3%
  • HTML 10.3%
  • JavaScript 7.6%
  • Nix 2.9%
  • Other 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium 8efe84c41f
All checks were successful
ci / fmt / clippy / test / deny / types / bundle (pull_request) Successful in 1m29s
ci / fmt / clippy / test / deny / types / bundle (push) Successful in 58s
feat(web): paint a recording's meta.theme hint
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
2026-10-14 17:16:50 +09:00
.forgejo/workflows deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
bench deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
docs deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
nix deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
src deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
tests fix(web): read the felis-cast v1 envelope 2026-10-14 16:20:08 +09:00
web feat(web): paint a recording's meta.theme hint 2026-10-14 17:16:50 +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 deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
Cargo.toml fix(web): read the felis-cast v1 envelope 2026-10-14 16:20:08 +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 deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +09:00
flake.nix deps: follow felis to v0.1.0 (#3) 2026-09-26 20:57:55 +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): paint a recording's meta.theme hint 2026-10-14 17:16:50 +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 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-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. Its attach reply carries the connection's effective protocol minor, which the view exposes as protocolMinor(); 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 one Attention variant) and jumpPrompt() for OSC 133 marks — both ride the per-stream path the broker routes.
  • ⚠️ Search and scrollback export are unreachable from a browser. They left InputMsg when felis split the wire into per-family correlated conversations: they are Search / Region requests whose request ids the connection driver allocates, and the gateway's broker routes neither family (felis-web-gateway docs/broker.md, "Stream frames"). search(), cancelSearch() and captureText() 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 (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 }) names the key through the same path a physical keystroke takes (the daemon encodes it against the modes it owns), 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.
  • ✅ Recorded theme hint: a .fcast whose header carries meta.theme (the recorder's base theme) paints with its foreground, background and cursor while the host has picked no theme; a theme attribute or createTerminal({ theme }) replaces the hint whole, and setTheme(null) (or removing the attribute) returns to it. The hint's palette is not applied, since the palette here is client policy.
  • ✅ Host-selectable cell size: a font-size="<px>" attribute (and createTerminal({ fontSize }) / setFontSize()), clamped to 8–32 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). It needs the recording to carry Scrolled directives at all — a plain RowDelta overwrite 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 sends InputMsg::Viewport and the daemon re-ships the scrolled rows plus a ViewportState the view mirrors (viewport/viewportMax). 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 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 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+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 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

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