Terminal that redraws the boundary of what a terminal is responsible for
  • Rust 97.3%
  • Shell 1%
  • Nix 0.9%
  • Python 0.5%
  • Just 0.2%
  • Other 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium bc1945aeaa
Some checks failed
fuzz / cargo fuzz nightly long-run (push) Has been skipped
build / build and cache felis (nix) (push) Successful in 1m58s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m24s
pr / nix flake check (push) Successful in 15s
pr / cargo build / clippy / test / deny (push) Failing after 1m18s
pr / generated code is current (push) Successful in 2s
pr / cargo check (Windows cross) (push) Successful in 58s
pr / cargo check (MSRV) (push) Successful in 1m22s
build / build and cache felis (macos) (push) Successful in 27m2s
fix(client): keep the window open when a superseded connection closes
A session switch detaches its predecessor, so the daemon closing that
stream is the switch completing — on the wire it is indistinguishable
from the daemon itself dying, and the client read every such close as
the latter and called `event_loop.exit()`. The window vanished mid-work
with no crash report and nothing in the log but "daemon disconnected;
exiting" while the daemon went on serving.

Aborting the superseded reader was the only thing standing between that
EOF and the exit, and it is a race it can lose: `JoinHandle::abort`
takes effect at the task's next yield, which a reader already returning
from EOF has passed. Two switches inside a millisecond — a run/pipe
transient exiting straight into a deferred `Reattach` push — are enough
to lose it, and by the time the stale EOF lands both switches have
finished, so every transition guard reports "nothing in flight".

Number each connection instead and stamp the number onto the events
whose meaning is scoped to one: a close, a grid frame, an image frame.
A stamp that is not the live one is ignored. Window-scoped events
(`Reattach`, `SessionExited`, `Evicted`) stay unstamped on purpose —
they are addressed to the window, not to the connection that delivered
them, and dropping a `Reattach` that arrived just before a switch would
lose a `felis sessions switch` the CLI already reported as delivered.
The abort stays as an optimisation, no longer as the barrier.
2026-08-03 17:57:58 +09:00
.cargo chore: right-size the proto codegen tooling config 2026-07-07 11:30:31 +09:00
.claude feat(client): give the window backdrop a per-OS key pair 2026-07-13 23:38:21 +09:00
.config feat(protocol)!: split the IPC ControlMsg into six role families 2026-07-05 22:13:04 +09:00
.forgejo/workflows chore: move buf into treefmt/git-hooks, add buf breaking 2026-07-07 12:36:56 +09:00
crates fix(client): keep the window open when a superseded connection closes 2026-08-03 17:57:58 +09:00
docs fix(client): keep the window open when a superseded connection closes 2026-08-03 17:57:58 +09:00
fuzz feat: felis ssh — re-dial a window onto a different daemon 2026-07-18 18:25:41 +09:00
nix feat: report cli/client/daemon build hashes in --version 2026-07-24 18:45:32 +09:00
scripts chore: route Criterion benches through scripts/bench.py; add nightly CI 2026-06-11 13:32:21 +09:00
share/terminfo docs: dissolve the ADR layer into the design docs 2026-06-08 00:44:26 +09:00
skills/felis fix: make a relative --cwd and a non-UTF-8 launch dir resolvable 2026-07-27 11:46:59 +09:00
.gitignore chore: adopt cargo-mutants for periodic mutation testing 2026-06-16 22:00:29 +09:00
buf.gen.yaml feat(protocol): add felis.v1 proto schema, generated wire types, buf codegen 2026-07-07 00:51:08 +09:00
buf.yaml feat(protocol): add felis.v1 proto schema, generated wire types, buf codegen 2026-07-07 00:51:08 +09:00
Cargo.lock feat: open new sessions in the caller's directory, not the daemon's / 2026-07-22 00:15:47 +09:00
Cargo.toml feat(daemon): start pipe/run transients in the session's OSC 7 cwd 2026-07-27 11:34:31 +09:00
CLAUDE.md docs: dissolve research notes into the Diátaxis quadrants 2026-07-13 15:01:10 +09:00
clippy.toml build: raise MSRV to 1.88 and add devShells.msrv verification 2026-05-30 14:39:49 +09:00
deny.toml chore: restore the cargo-deny gate for public release 2026-07-05 00:49:23 +09:00
flake.lock chore(flake): update flake.lock 2026-06-28 22:47:09 +09:00
flake.nix feat: report cli/client/daemon build hashes in --version 2026-07-24 18:45:32 +09:00
justfile feat(protocol): add felis.v1 proto schema, generated wire types, buf codegen 2026-07-07 00:51:08 +09:00
LICENSE Lock in license, filesystem layout, OSC 66, and Sixel as permanent reject 2026-05-10 02:19:06 +09:00
README.md docs: document the felis ssh cross-carrier retarget 2026-07-18 18:27:18 +09:00
rustfmt.toml ADR 0007: Nix flake as the canonical development environment 2026-05-10 11:00:38 +09:00

felis

felis is a terminal that redraws the boundary of what a terminal is responsible for. Written in Rust, it draws on kitty for protocol fidelity, alacritty for simplicity, and Kakoune for design discipline.

State lives in a long-running daemon; the client owns only the window. Closing the window keeps the shell running, and the next client — local or across SSH — picks up exactly where the last one left off.

Quick start

felis ships from a Nix flake, so the fastest path needs only the Nix package manager with flakes enabled — no manual toolchain setup, no separate daemon to start.

# Try it without installing anything.
nix run git+https://git.natsukium.com/natsukium/felis

# Or install it to your profile, then just run `felis`.
nix profile install git+https://git.natsukium.com/natsukium/felis
felis

Launching felis opens a window and auto-spawns the background daemon off $PATH — there is nothing else to start. Close the window and your shell keeps running; the next felis reattaches exactly where you left off.

From there:

  • Persistent sessions — close the window, then felis sessions list and felis attach <id-prefix> to pick a detached shell back up.
  • Cross-hostfelis --host user@remote opens a window backed by a remote daemon over plain SSH, or felis ssh user@remote re-points the window you are in at that daemon (and felis ssh --local brings it back).
  • Scriptingfelis sessions {list, info, send, capture, search, spawn, kill} drives the daemon from shell scripts; see CLI and automation.

The end-user manual — install, getting started, configuration, keybindings, CLI reference, troubleshooting — is published at https://felis-docs.pages.dev and maintained in this repository under docs/, organized on the Diátaxis map (tutorials, how-to guides, reference, explanation).

Prefer to build from source or hack on felis? See Building from source.

Features

  • Persistent sessions. Closing the client window does not kill the shell; felis attach <id-prefix> rehydrates a detached one, and chords cycle or spawn sessions in-window (keybindings).
  • Cross-host attach over SSH. felis --host user@remote opens a window backed by a remote daemon — frames ride plain SSH stdio (ssh <host> felis-daemon relay), no listening network service and no auth layer beyond SSH itself.
  • CLI for scripted automation. felis sessions {list, info, send, capture, spawn, search, kill} with first-class --json — see CLI and automation.
  • Scrollback search. felis sessions search <id> <pattern> greps your session's history; in-window, Ctrl+Shift+F opens a search bar with highlighted matches and n / N navigation.
  • Modern terminal protocols. True colour, italics, the xterm underline range (curly / dotted / dashed, with underline colour), OSC 8 hyperlinks, mouse SGR, bracketed paste, focus tracking, synchronized output, the Kitty keyboard protocol, OSC 133 semantic prompts, Kitty graphics (animation and shared memory included) and text sizing.
  • Real text shaping. Per-codepoint font fallback (CJK + symbols + colour emoji + Nerd Font), opt-in OpenType features / ligatures, platform-native IME (AppKit / zwp_text_input_v3 / TSF / XIM), live font zoom with Ctrl+Wheel.
  • Familiar selection and scrolling. Linear / rectangle / word / line selection with xterm gestures; Linux PRIMARY auto-copy and middle-click paste (the gesture table).
  • Hot config reload. Edit the config and save; theme and font changes apply without restarting the daemon or losing scrollback.

Configuration

One TOML file at the platform-native path, hot-reloaded on save (and on Ctrl+Shift+R). Every field — font, theme, palette, cursor, clipboard, window, keymap — is documented with its defaults and failure behaviour in the configuration page; the default chord table and the deliberately-unbound session actions are in keybindings. A generated JSON Schema gives TOML-aware editors completion and validation.

Install with Nix

nix run / nix profile install above are the whole story for a quick try. For a managed setup the flake also exposes the package, a home-manager module (installs the binaries, writes config.toml from Nix, wires TERMINFO_DIRS), and a Stylix hook that drives the palette, font, and background opacity from the active scheme — all documented on the install guide.

CLI and automation

felis sessions <verb> exposes every IPC operation a daemon accepts as a typed shell command — list, info, spawn, send, capture, search, kill — with --json on every read verb and exit codes under contract (0 ok, 1 operation-domain failure, 2 no daemon / protocol error). Common patterns — pre-warming a session, grepping scrollback, snapshotting for a bug report, reaping stale sessions, editor integration — are collected in the how-to guides with the verb table in the CLI reference; the full surface contract and the wire it rides live in docs/reference/ipc.md, which cross-language clients can speak directly.

Status

felis is a prototype, and runnable as one.

Works today:

  • Day-to-day shell + editor use. neovim, helix, lazygit, fish, fzf, and similar TUIs behave correctly: mouse, focus tracking, bracketed paste, OSC 8 hyperlinks, OSC 133 semantic prompts, the Kitty keyboard protocol, synchronized output, true colour, italics, and the full xterm underline range.
  • Persistent sessions. Close the window, list with felis sessions list, reattach with felis attach <id>.
  • Cross-host attach. felis --host user@remote over plain SSH stdio.
  • CLI for scripted automation. felis sessions {list, info, send, capture, spawn, search, kill} — see "CLI and automation" above.
  • Kitty graphics (yazi, mdcat, presenterm previews, mpv video): protocol, daemon-side image store, renderer atlas, animation (a=a/f/c), and shared-memory transmission (t=s) all wired.
  • Kitty text-sizing protocol (OSC 66): renderer paints scaled multi-cell runs at integer and fractional sizes.
  • Scrollback search (REQ-607): daemon-side substring + regex, exposed via felis sessions search and the in-window Ctrl+Shift+F bar with highlighted matches and n / N navigation.
  • OpenType font features and programming-font ligatures, opt-in via font.features (defaults off so cell metrics never shift unasked).
  • IME on macOS / Wayland / Windows / X11; per-codepoint font fallback covering CJK, symbols, colour emoji, and Nerd-Font glyphs.
  • xterm-flavoured selection, clipboard, scrollback, and font zoom (Cmd+* on macOS, Ctrl+Shift+* elsewhere).
  • Hot config reload — theme and font changes swap into the running client without restarting the daemon.
  • Bidi-override visible markers (Trojan-Source protection).

Not yet (polish and backlog, not core-functionality gaps):

  • Read-only attach (lifts the "session must be detached" limitation on felis sessions send / capture / search).

The full list of open work lives in docs/backlog.md.

Design

felis is not defined by how many features it has — it redraws the line of what a terminal is responsible for, pulling inward what the conventional stack pushed out and pushing outward what a terminal conventionally pulls in:

  • Pulled inward. Session ownership and persistence — felis's own daemon owns the PTY, grid, and scrollback, so the shell outlives the window — and full-fidelity rendering, because nothing sits between the shell and the screen to flatten Kitty graphics or text sizing.
  • Pushed outward. Layout (tabs, panes, splits) to the window manager; extension to external processes over a versioned IPC, never an embedded Lua / Python / Wasm host; intent-guessing (URL detection, content sniffing, smart quoting) nowhere at all — the user's bytes stay the user's bytes.

Inside that boundary felis is maximalist — render everything, fast. At the boundary it is strict — delete before adding. No second system lives inside the terminal.

The full rationale is in docs/explanation/vision.md; the eight non-negotiable principles, each paired with a concrete violation test, live in docs/explanation/principles.md.

Building from source

felis is a prototype; to hack on it, build from cargo. The Nix flake at the repo root is the single source of truth for the toolchain (nightly Rust, the runtime libs wgpu / winit need, the formatter, and the pre-commit hook).

# Drop into the dev shell — installs the pre-commit hook automatically.
nix develop

# Run the daemon, then the client.
cargo run -p felis-daemon -- serve &
cargo run -p felis-client

# List detached sessions and reattach by id prefix.
cargo run -p felis-cli -- sessions list
cargo run -p felis-cli -- attach <id-prefix>

# Cross-host attach over SSH.
cargo run -p felis-client -- --host user@remote

Documentation

All documentation — the end-user manual and the design docs — is published at https://felis-docs.pages.dev and lives in this repository under docs/ — next to the code it documents, so it cannot rot; the felis-docs repository only holds the site machinery and vendors the content via a submodule.

For contributors, docs/README.md is the entry point. The design docs are the source of truth for every feature decision — each records the rationale and the rejected alternatives alongside the current design.

License

Apache-2.0. See LICENSE.