- Emacs Lisp 94.8%
- Nix 5.2%
|
Some checks failed
ci / nix flake check (push) Failing after 23s
A felis buffer is a fixed ROWS-line viewport (the daemon owns scrollback), so the window must always start at row 0. Nothing enforced that: the blank rows carry a trailing newline, so point can momentarily reach the line past the last row, and with `scroll-conservatively' high the window shifts down and hides row 0 — a full-screen TUI's top border (lazygit, btop) then never reappears. Re-anchor `window-start' to point-min on every cursor move. Also pin `line-spacing' to 0 rather than nil: a nil buffer value falls through to the frame's `line-spacing' parameter, so a user's global leading still leaks into the grid and opens a gap that stops box-drawing verticals from connecting. 0 forces no extra space. Assisted-by: Claude Code Opus 4.8 |
||
|---|---|---|
| .forgejo/workflows | ||
| .gitignore | ||
| felis-bench.el | ||
| felis-contract.el | ||
| felis-tests.el | ||
| felis.el | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.org | ||
felis.el
- What this is
- Compared to vterm / eat / ghostel
- Status
- Requirements
- Usage
- Wire contract
- Development
- License
What this is
felis.el is an Emacs client for felis, a GPU-accelerated terminal
emulator with a daemon/client split. Unlike vterm, eat, and
ghostel — which all embed a VT engine inside the Emacs process —
felis.el emulates nothing. The felis daemon already owns the terminal
grid, scrollback, and even Kitty-graphics image compositing; this client
only:
- renders the grid diffs the daemon pushes over a socket, and
- forwards keyboard input back.
Because state lives in the daemon, closing the Emacs buffer (or all of
Emacs) does not kill the shell — reattaching replays a fresh snapshot.
This makes felis.el the thinnest Emacs terminal: no C/Zig module, no
libvterm, no in-elisp VT parser.
Compared to vterm / eat / ghostel
vterm (libvterm via a C module), eat (a VT parser in Emacs Lisp), and
ghostel (libghostty-vt via a prebuilt native module) all run the VT
engine inside the Emacs process. felis.el takes the opposite stance:
the engine is a separate daemon, and Emacs is one of its views. That
single choice is where its strengths come from — they are properties of
the architecture, not a longer feature list.
- The session outlives Emacs, and is shareable. Because the shell, grid
and scrollback live in the daemon, quitting (or crashing) Emacs leaves
the session running; reattaching replays a snapshot. The same session
can be driven from several frontends at once — this Emacs client, the
native
felisTUI, or another Emacs — liketmux=/=abduco, but as an Emacs buffer. An in-process terminal cannot offer this: when its Emacs dies, so does the session. - Pure Emacs Lisp — zero native code. No dynamic module, no
libvterm, and no prebuilt binary downloaded at runtime. That means no coupling to the Emacs module ABI or to a specific OS/CPU, an auditable single file (~2k lines), and reproducible builds (see the Nix flake and the wire-contract check). It runs wherever Emacs and a Unix socket do. - Process isolation. A bug or crash in VT parsing stays in the daemon; it cannot take Emacs down with it. ghostel's engine shares Emacs's process.
- A thin, shared view. The one correct VT implementation lives in the
daemon and is reused by every frontend.
felis.elis a renderer, not a terminal. - Remote terminals render natively. A client with an in-process engine
must drop to plain Emacs process machinery for a remote host (ghostel
bypasses its native module for TRAMP buffers), so remote shells lose the
rendering the engine exists for.
felis.eljust runssshinside a daemon PTY (M-x felis-remote), so a remote shell draws through the exact same path as a local one — no degraded mode. (A daemon can also run on the remote and be reached by forwarding its socket, for remote-side session persistence.)
Where the others currently lead, and what felis.el is working toward:
maturity and breadth of Emacs integration (eshell=/=comint, Evil) and
inline input-method composition. (felis-compile, felis-copy-mode,
plain-URL linkifying, felis-insert for input-method text, and
felis-remote already cover several of these — see below.) These are being
closed; the architectural strengths above are not something an in-process
design can adopt without becoming a daemon/client split itself.
Status
Early scaffold (0.1.0-alpha). Working today:
- Unix-socket transport with the felis length-prefixed frame codec.
- The postcard
Hello/Welcomebootstrap, then JSON for every subsequent frame (felis ships row payloads as inline structural JSON to JSON-negotiating peers, so no postcard decoder is needed in elisp). M-x feliscreates a new session;M-x felis-createcreates one running a specific program (felis'sxterm -eanalog, e.g.htop);M-x felis-attachreattaches to an existing one (the daemon's session list rides the postcard Welcome, the one message felis.el postcard-decodes; everything else is JSON).M-x felis-kill-sessiondestroys one by name (a pre-attachDestroySessionon a throwaway connection, so it works even when no buffer is attached to the target).- Project terminals:
M-x felis-projectopens (or switches to) a session for the currentproject.elproject, in a buffer named for it, andcd's the shell into the project root once it starts (sent as input, so the shell keeps its full environment — the daemon's default-shell spawn ignores a requested cwd). - Remote terminals:
M-x felis-remote HOSTopens a session thatexec'sssh HOSTin the local daemon's PTY, so a remote shell renders through the same native path as a local one (no fallback to plain Emacs process machinery). It needs no daemon on HOST and keeps your SSH agent/config (it execs from a full-environment shell). For remote-side session persistence instead, run a daemon on HOST and forward its socket (ssh -L) tofelis-socket-path— the socket carrier speaks JSON, while the SSH--stdiocarrier is postcard-only and so off-limits to this JSON-only client. - Render
RowDelta/RowDeltaBatchrows with colors and attributes — 16/256/truecolorfg=/=bg, bold, faint, italic, reverse, strike, overline, conceal (SGR 8, painted invisible), and styled underlines (incl. curly) as Emacs faces fromattr_runs; cursor placement; title andOSC 7cwd tracking. (Blink and double/dotted/dashed underlines have no Emacs face equivalent and are rendered as their nearest form.) - Follows the Emacs window size: opens at the window's geometry and
sends
Resizeas it changes (the daemon reflows;GridSizere-syncs). - Wide glyphs (CJK / full-width) stay aligned: a double-width
Charfills both of its grid columns and itsSpacerhalf emits nothing, and the cursor tracks display columns, so cells after a wide glyph keep their place. OSC 8hyperlinks: linked runs are clickable (mouse-1 / mouse-2, orC-c C-oat point) and show their URI on hover, opened viabrowse-url. Link styling stays the shell's call (it rides the cell attributes), so the id only adds interactivity. Barehttp(s)/ftpURLs the program never marked up are linkified too (goto-address-style; toggle withfelis-linkify-urls).- Kitty-graphics images on graphical frames: the daemon streams raw
pixels (
Header/Chunk/Complete) and mirrors its placement table, and felis.el reassembles each image, alpha-composites RGBA over the frame background to a native-readable PPM, and paints it across the placement's cell box with sliced-image overlays. Animation follows the daemon'sShowFrame(no client-side clock). On a TTY frame the data is tracked but nothing is drawn. - Full keyboard input in a semi-char model (after
term=/=eat): printable text and UTF-8, every control code (C-c→ SIGINT,C-d→ EOF,C-z,C-r, …), Meta combos, and the navigation / editing / function keys (arrows, Home/End, PgUp/PgDn, Ins/Del, F1–F12) are forwarded to the daemon. OnlyC-x,C-g,M-x, andM-wstay with Emacs (window management, quit,M-x, copy) — customize viafelis-reserved-keys. The Escape key sendsESC(soviworks) whileESCkeeps its Meta-prefix role internally. - Input methods:
Shift+Ctrl+i(M-x felis-insert) reads text with your Emacs input method active (quail, e.g. for Japanese / CJK) and forwards it. The grid buffer is read-only — the daemon owns it — so composition happens in the minibuffer rather than inline. - Send text from elsewhere:
M-x felis-send-region/felis-send-linepipe code from another buffer into a session and run it (bracketed-paste safe, multi-line);felis-send-stringprompts. Handy for REPL workflows; picks the session automatically, or prompts when several are live. - Literal key passthrough:
Shift+Ctrl+q(M-x felis-send-next-key) reads one key withread-keyand forwards it to the program even when felis binds it — the escape hatch for sending a chord felis reserves for Emacs (itsquoted-insertanalog).M-x felis-clearsendsC-l. - Hyperlink motion:
Shift+Ctrl+n/Shift+Ctrl+pjump between hyperlinks (OSC 8and linkified URLs);RET/ click follows the one at point. M-x felis-redrawrepaints the grid buffer (re-lays image overlays and forces a redisplay) to recover from a display glitch without reconnecting.- Kitty keyboard protocol: when a program pushes the flags (
CSI > … u, e.g. neovim, helix, kakoune) the disambiguated keys go out asCSI keycode;mods u, soC-iis told apart fromTab,C-mfromEnter, andEscapefrom an escape sequence. (Emacs reports presses only, so the release-event flag is a no-op; arrows / function keys keep their legacy forms, as the native client also leaves them.) - Mouse reporting: clicks, drags, and the wheel are forwarded to a program
that enabled mouse tracking (
?1000/?1002/?1003), asInputMsg::Mouse; otherwise the wheel scrolls the viewport, a left-button drag selects grid text the ordinary Emacs way (region + primary,M-wto copy), and clicks followOSC 8links. - Mouse-pointer shape: a program's
OSC 22cursor request (textover an input,pointerover a link) maps to the matching Emacs pointer. - Cursor shape and visibility: a program's
DECSCUSRblock / underline / bar cursor (vim, modern editors) maps to the Emacscursor-type, and a hidden cursor (DECTCEM) draws none. - Reports its environment to the session: window focus (
?1004), Emacs's light/dark theme (ColorScheme, so aDECSET 2031program follows the Emacs theme), and a desktop notification (OSC 9/99/777) flags the buffer for attention viafelis-notify-function. - Paste Emacs's clipboard into the terminal with
Shift-Insert; a program's ownOSC 52clipboard write lands on the kill-ring. - Autospawns the daemon:
M-x felisstartsfelis-daemon(detached, so it outlives Emacs) when the socket is absent, the way the native client does. Disable withfelis-autospawn; point elsewhere withfelis-daemon-command. - Killing the buffer just detaches — the daemon parks the session and
shell, so
felis-attachcan pick it back up later. - Scrollback:
Shift-PgUp/Shift-PgDn(andShift-Home/Shift-End) and the mouse wheel lift the viewport into the daemon's scrollback; the mode line shows the offset and typing snaps back to the live bottom. The unshifted PgUp/PgDn still reach a full-screen app (less,vi).Shift+Ctrl+Z/Shift+Ctrl+Xjump the viewport to the previous / next shell prompt (OSC 133). - Copy-mode:
Shift+Ctrl+c(M-x felis-copy-mode/felis-scrollback) opens a read-onlyfelis-snapshot-modebuffer holding the whole scrollback plus the live screen, where Emacs motion,isearch, region selection andw/M-wcopy work;qreturns to the terminal. The daemon owns the history, so this is a frozen snapshot rather than the live buffer — the daemon-backed equivalent of an in-process copy-mode.M-x felis-searchruns the daemon's incremental search instead and lists the hits, whereRETlifts the viewport to a match. - Compile with
next-error:M-x felis-compileruns a build command in the real terminal — so its color, progress bars and TUI output render correctly — and, on the daemon'sOSC 133command-end mark (which also carries the exit code), harvests the command's output into acompilation-modebuffer wherenext-errorjumps to the matches.M-x felis-recompilerepeats the last one. - Command status (
OSC 133): the mode-line shows▶while a command runs and✗Nwhen one exits non-zero; setfelis-command-notify-thresholdto be notified when a long command finishes while its buffer is off-screen.felis-command-finished-functionsis the hook these build on. - Session switching:
Shift+Ctrl+]/Shift+Ctrl+[re-attach the buffer to the next / previous daemon session, and an externalfelis sessions switchreaches this client through the daemon'sReattachpush.M-x felis-switch-sessionre-attaches the buffer to a session picked by completion (built oncompleting-read, so Consult, Vertico, etc. render the list), andM-x felis-attachstill picks one by name. - Region scrolls are applied as a shift (
GridMsg::Scrolled) rather than a full repaint, carrying the moved rows' faces and images along. - Reconnect after a dropped socket:
M-x felis-reconnectre-attaches the buffer to its session, which the daemon kept running across the drop — so a transient disconnect leaves the shell and its scrollback intact. The closed-connection notice points at the command. - Unicode-placeholder image placements (Kitty
U=1, used by e.g. yazi's preview): the placeholder cells (charU+10EEEEwith row/column/image-id ranks in combining diacritics) are decoded and the image is painted over the spanning region with per-tile sliced-image overlays.
Not yet implemented (contributions/iteration welcome):
- Pixel-exact width when Emacs and the daemon disagree on a glyph's column count (some emoji and East-Asian-ambiguous characters). Both sides derive width from the same Unicode East-Asian-Width tables, so this only bites font-dependent emoji.
- Inline input-method composition in the grid:
felis-insertcomposes in the minibuffer because the grid buffer is read-only (the daemon owns it). - Observer-mode desktop notifications with content (
felis sessions subscribe'sNotifyEvent) and pull-pacing (NextGridFrame) — the attached client tracks neither yet.
Requirements
- Emacs 29.1+ (uses the built-in
json-serialize/json-parse-stringand bignums for the daemon'su128session ids). - A felis daemon.
felis.elconnects to its Unix socket, autospawningfelis-daemonwhen none is running (unlessfelis-autospawnis nil).
Usage
(add-to-list 'load-path "/path/to/felis.el")
(require 'felis)
Then M-x felis to spawn a new session, or M-x felis-attach to
reattach to one already running in the daemon. It resolves the daemon
socket the same way the daemon does
($XDG_RUNTIME_DIR/felis/daemon.sock, falling back to
$TMPDIR/felis.<uid>/daemon.sock); override with felis-socket-path.
Note: the daemon must be built from a revision that serves JSON to JSON-negotiating clients (felis commit "drive post-handshake frames with the negotiated encoding" or later). An older daemon negotiates JSON but still sends postcard, which felis.el cannot read.
Wire contract
See felis docs/architecture/ipc.md. In brief:
- Frames are a 12-byte little-endian header (
len:u32covering the rest,seq:u32,kind:u16,msg_id:u16) followed by a body. Kinds: Control=0, Input=1, Grid=2, Image=3. - The
Hello/Welcomehandshake is always postcard (a peer cannot announce its encoding before the daemon reads itsHello).felis.elsends one hand-built postcardHelloadvertising JSON, then switches to JSON. The exactHellobytes are pinned infelis.el. - Post-handshake everything is JSON. The daemon transcodes each row to
structural JSON (
{"Rle":{...}}) for JSON peers, sojson-parse-stringreads the grid directly.
Development
- Tests:
nix flake checkruns the ERT suite (felis-tests.el) and the wire-contract cross-check (felis-contract.el, which parses the pinned felis Rust source and asserts felis.el's capability bits, Kitty flag bits and enum serde casing still match). Run ERT directly withemacs -Q --batch -l felis.el -l felis-tests.el -f ert-run-tests-batch-and-exit. - Lint / pre-commit: hooks are wired through
git-hooks.nix, so the same checks run innix flake checkand on each commit.nix developinstalls them; they byte-compilefelis.elwith warnings-as-errors, runpackage-lint(MELPA readiness), and keepflake.nixnixfmt-formatted.checkdocis run by hand (M-x checkdoc-file) rather than gated — felis.el keeps a few deliberate warnings (the lowercasefelis:message prefix and terminal-key names that are not Emacs commands). - Benchmarks:
felis-bench.elsnapshots the per-frame hot paths (row decode, grid apply, face build). Runemacs -Q --batch -l felis.el -l felis-bench.el -f felis-bench-run; CI records the table nightly (.forgejo/workflows/bench.yml).
License
Apache-2.0, matching the felis project.