No description
  • Emacs Lisp 94.8%
  • Nix 5.2%
Find a file
natsukium 60d85b05ee
Some checks failed
ci / nix flake check (push) Failing after 23s
fix: keep the grid viewport pinned to row 0 and defeat global leading
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
2026-06-20 11:16:44 +09:00
.forgejo/workflows build: add a lint gate via git-hooks.nix 2026-06-19 15:58:28 +09:00
.gitignore chore: ignore nix build result symlinks 2026-06-19 09:01:19 +09:00
felis-bench.el feat: felis-bench.el microbenchmark harness + CI snapshot 2026-06-19 15:09:57 +09:00
felis-contract.el feat: mirror cursor shape and visibility (DECSCUSR / DECTCEM) 2026-06-19 13:17:15 +09:00
felis-tests.el feat: project sessions and ghostel-style nav/input commands 2026-06-19 16:39:30 +09:00
felis.el fix: keep the grid viewport pinned to row 0 and defeat global leading 2026-06-20 11:16:44 +09:00
flake.lock build: add a lint gate via git-hooks.nix 2026-06-19 15:58:28 +09:00
flake.nix build: add a lint gate via git-hooks.nix 2026-06-19 15:58:28 +09:00
LICENSE feat: scaffold felis.el — Emacs frontend for the felis daemon 2026-06-17 23:08:47 +09:00
README.org feat: felis-switch-session to pick a session by completion 2026-06-20 01:45:04 +09:00

felis.el

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:

  1. renders the grid diffs the daemon pushes over a socket, and
  2. 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 felis TUI, or another Emacs — like tmux=/=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.el is 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.el just runs ssh inside 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 / Welcome bootstrap, 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 felis creates a new session; M-x felis-create creates one running a specific program (felis's xterm -e analog, e.g. htop); M-x felis-attach reattaches 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-session destroys one by name (a pre-attach DestroySession on a throwaway connection, so it works even when no buffer is attached to the target).
  • Project terminals: M-x felis-project opens (or switches to) a session for the current project.el project, in a buffer named for it, and cd'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 HOST opens a session that exec's ssh HOST in 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) to felis-socket-path — the socket carrier speaks JSON, while the SSH --stdio carrier is postcard-only and so off-limits to this JSON-only client.
  • Render RowDelta / RowDeltaBatch rows with colors and attributes — 16/256/truecolor fg=/=bg, bold, faint, italic, reverse, strike, overline, conceal (SGR 8, painted invisible), and styled underlines (incl. curly) as Emacs faces from attr_runs; cursor placement; title and OSC 7 cwd 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 Resize as it changes (the daemon reflows; GridSize re-syncs).
  • Wide glyphs (CJK / full-width) stay aligned: a double-width Char fills both of its grid columns and its Spacer half emits nothing, and the cursor tracks display columns, so cells after a wide glyph keep their place.
  • OSC 8 hyperlinks: linked runs are clickable (mouse-1 / mouse-2, or C-c C-o at point) and show their URI on hover, opened via browse-url. Link styling stays the shell's call (it rides the cell attributes), so the id only adds interactivity. Bare http(s) / ftp URLs the program never marked up are linkified too (goto-address-style; toggle with felis-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's ShowFrame (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, F1F12) are forwarded to the daemon. Only C-x, C-g, M-x, and M-w stay with Emacs (window management, quit, M-x, copy) — customize via felis-reserved-keys. The Escape key sends ESC (so vi works) while ESC keeps 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-line pipe code from another buffer into a session and run it (bracketed-paste safe, multi-line); felis-send-string prompts. 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 with read-key and forwards it to the program even when felis binds it — the escape hatch for sending a chord felis reserves for Emacs (its quoted-insert analog). M-x felis-clear sends C-l.
  • Hyperlink motion: Shift+Ctrl+n / Shift+Ctrl+p jump between hyperlinks (OSC 8 and linkified URLs); RET / click follows the one at point.
  • M-x felis-redraw repaints 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 as CSI keycode;mods u, so C-i is told apart from Tab, C-m from Enter, and Escape from 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), as InputMsg::Mouse; otherwise the wheel scrolls the viewport, a left-button drag selects grid text the ordinary Emacs way (region + primary, M-w to copy), and clicks follow OSC 8 links.
  • Mouse-pointer shape: a program's OSC 22 cursor request (text over an input, pointer over a link) maps to the matching Emacs pointer.
  • Cursor shape and visibility: a program's DECSCUSR block / underline / bar cursor (vim, modern editors) maps to the Emacs cursor-type, and a hidden cursor (DECTCEM) draws none.
  • Reports its environment to the session: window focus (?1004), Emacs's light/dark theme (ColorScheme, so a DECSET 2031 program follows the Emacs theme), and a desktop notification (OSC 9/99/777) flags the buffer for attention via felis-notify-function.
  • Paste Emacs's clipboard into the terminal with Shift-Insert; a program's own OSC 52 clipboard write lands on the kill-ring.
  • Autospawns the daemon: M-x felis starts felis-daemon (detached, so it outlives Emacs) when the socket is absent, the way the native client does. Disable with felis-autospawn; point elsewhere with felis-daemon-command.
  • Killing the buffer just detaches — the daemon parks the session and shell, so felis-attach can pick it back up later.
  • Scrollback: Shift-PgUp / Shift-PgDn (and Shift-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+X jump 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-only felis-snapshot-mode buffer holding the whole scrollback plus the live screen, where Emacs motion, isearch, region selection and w / M-w copy work; q returns 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-search runs the daemon's incremental search instead and lists the hits, where RET lifts the viewport to a match.
  • Compile with next-error: M-x felis-compile runs a build command in the real terminal — so its color, progress bars and TUI output render correctly — and, on the daemon's OSC 133 command-end mark (which also carries the exit code), harvests the command's output into a compilation-mode buffer where next-error jumps to the matches. M-x felis-recompile repeats the last one.
  • Command status (OSC 133): the mode-line shows while a command runs and ✗N when one exits non-zero; set felis-command-notify-threshold to be notified when a long command finishes while its buffer is off-screen. felis-command-finished-functions is the hook these build on.
  • Session switching: Shift+Ctrl+] / Shift+Ctrl+[ re-attach the buffer to the next / previous daemon session, and an external felis sessions switch reaches this client through the daemon's Reattach push. M-x felis-switch-session re-attaches the buffer to a session picked by completion (built on completing-read, so Consult, Vertico, etc. render the list), and M-x felis-attach still 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-reconnect re-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 (char U+10EEEE with 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-insert composes in the minibuffer because the grid buffer is read-only (the daemon owns it).
  • Observer-mode desktop notifications with content (felis sessions subscribe's NotifyEvent) and pull-pacing (NextGridFrame) — the attached client tracks neither yet.

Requirements

  • Emacs 29.1+ (uses the built-in json-serialize / json-parse-string and bignums for the daemon's u128 session ids).
  • A felis daemon. felis.el connects to its Unix socket, autospawning felis-daemon when none is running (unless felis-autospawn is 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:u32 covering the rest, seq:u32, kind:u16, msg_id:u16) followed by a body. Kinds: Control=0, Input=1, Grid=2, Image=3.
  • The Hello / Welcome handshake is always postcard (a peer cannot announce its encoding before the daemon reads its Hello). felis.el sends one hand-built postcard Hello advertising JSON, then switches to JSON. The exact Hello bytes are pinned in felis.el.
  • Post-handshake everything is JSON. The daemon transcodes each row to structural JSON ({"Rle":{...}}) for JSON peers, so json-parse-string reads the grid directly.

Development

  • Tests: nix flake check runs 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 with emacs -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 in nix flake check and on each commit. nix develop installs them; they byte-compile felis.el with warnings-as-errors, run package-lint (MELPA readiness), and keep flake.nix nixfmt-formatted. checkdoc is run by hand (M-x checkdoc-file) rather than gated — felis.el keeps a few deliberate warnings (the lowercase felis: message prefix and terminal-key names that are not Emacs commands).
  • Benchmarks: felis-bench.el snapshots the per-frame hot paths (row decode, grid apply, face build). Run emacs -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.