English | 简体中文
fe is a terminal text editor built in and programmable with Forth (Gforth). The editor and the Forth interpreter run in the same process and share the same dictionary — so you can reach into the editor's internal words and variables, and define new commands live.
It pairs that with a familiar, complete editing experience: vi-style modal editing, fuzzy-matching pickers, async live grep, an LSP client, multi-window splits, and undo/redo.
Four design choices set it apart:
- Forth-native, not just Forth-themed. The editor is a live Forth system. From the embedded REPL you can call the editor's internal words and variables directly, and
:define new commands on the fly — not a C program with a Forth skin on top. - A familiar, complete daily driver. vi-style modal editing, LSP, live grep, fuzzy file/buffer pickers, multiple windows, undo/redo, and keyboard macros. You don't give up ordinary editor capabilities to use Forth.
- The editor itself is modifiable on the fly. Configuration, command definitions, key bindings, and the extension mechanism are all Forth — they are the core of the project, not an implementation detail. Edit
~/.fe, then pressCtrl-xto reload. - A small, deliberate core — everything is customizable.
feintentionally does not ship an all-in-one feature set, because nothing is hard-wired: key bindings, commands, modes, the status line, and syntax highlighting are all redefinable live. Define new commands with:, rebind keys, drop them into~/.fe(or./.fe.fs), and pressCtrl-xto reload — no restart, no recompiling. Your extensions run in the same live Forth system as the editor itself.
“less is less, more is more”
The code is written in the handmade style, in the small:
- No hidden allocation. Most state lives in fixed
create … allotbuffers; buffers that grow (snap,hlmap,lstate, LSP buffers) are allocated once and reused.allocate/freeappears only at explicit lifecycle boundaries — reading a file, tearing down a buffer. - Data-driven, not object-heavy. Modes and keymaps are plain key-indexed tables; field offsets come from a
fld:defining word; extension points are a handful ofdeferwords and xt variables. - One script, no build system. The editor is a single
.fsfile (plus small focused modules) interpreted by Gforth at startup — no Makefile, no generated code.
You can read the whole editor end to end — and change it while it runs.
- Depends on Gforth 1.0 (0.7.9 may also work) plus a C compiler (only needed on first run to compile the termios/width bindings; afterwards the result is cached).
- live grep needs one of
rg,git, orgrep(rgis preferred; if absent it falls back togit grep→grep). - macOS / Linux only, ANSI / xterm terminals.
The core editor was originally written with only the most basic editing capabilities, and then went years without updates. In October 2026 it was refactored with AI assistance (the pi coding agent + DeepSeek): upgraded to Gforth 0.7.9 and later to Gforth 1.0.0, and extended with relatively heavyweight integrations such as the LSP client.
The project does not exclude AI assistance, but it rejects fully AI-generated code: the code should stay human-readable (even though Forth readability is not always great), and a human must remain in deep control of it.
macOS
brew install gforthDebian / Ubuntu
sudo apt install gforthCheck the version (it must be 1.0; 0.7.9 may also work. If your distro's package is too old, see "Building from source" below):
gforth --versionBuilding from source (generic, any Linux)
# Download gforth-1.0.0.tar.xz from https://gforth.org, extract it, then:
./configure
make
sudo make installOn the first run, Gforth's libcc compiles a few small bindings (termios, wcwidth, etc.) using your local C compiler; the result is then cached and later startups no longer need a compiler. Make sure
cc/gcc/clangis on your PATH.
cd forth # the directory containing fe.fs
./fe # start (fe is a symlink to fe.fs; fe.fs is a dual shell/gforth script)
./fe a.c b.txt # open multiple files (stops on the first)
./fe -e '4 tab-width !' a.c # evaluate Forth at startup (after ~/.fe; then open a.c)
./fe -e 'bye' # evaluate and exit without entering the editor
./fe -n # skip loading ~/.fe and ./.fe.fs
# or directly:
./fe.fs a.c
# or:
gforth fe.fs a.cIt's convenient to link fe into your PATH, e.g.:
ln -s "$PWD/fe" ~/bin/feAt startup (and on Ctrl-x reload), configuration layers are loaded by priority: ~/.fe (user) → ./.fe.fs (project root; the latter can override). Each layer is a Forth snippet — it can set variables and rebind keys:
4 tab-width ! \ tab display width (default 8)
0 linenr-default ! \ turn off line numbers by default (0=off 1=absolute 2=relative 3=hybrid)
1 regex-search ! \ use POSIX regex for search (default 0 = literal substring)
1 lp-keep-right ! \ pickers keep the right end of over-wide entries (default keeps head + tail …)
char Z ' cmd-qrep bind-command \ rebind query-replace to Z (config is interpreted: use char/' instead of [char]/['])See fe.example for a fuller sample (git blame + function navigation on the Space leader).
Commonly configurable variables:
| Variable | Default | Meaning |
|---|---|---|
tab-width |
8 | tab display width |
linenr-default |
1 | global line-number mode: 0=off 1=absolute 2=relative 3=hybrid |
linenr-scope |
0 | line-number scope: 0=per window 1=per buffer |
regex-search |
0 | 1=search uses POSIX regex; 0=literal substring |
lp-keep-right |
0 | 1=pickers keep the right end of over-wide entries |
Key rebinding uses bind-command (command mode), bind-insert (insert mode), bind-mode (any mode).
A configuration error in one layer is isolated (reported on the status line; it doesn't break the other layers).
| Key | Action |
|---|---|
h / l |
move cursor left / right (whole characters, no line crossing) |
k / j |
move cursor up / down (logical lines, keeping the target column) |
← → ↑ ↓ |
arrow-key movement |
Home / End |
start / end of line |
PgUp / PgDn / Ctrl-f / Ctrl-b |
page up / down |
0 / $ |
start / end of line |
w / b / e |
next word start / previous word start / word end |
gg / G |
start / end of file |
Ctrl-e / Ctrl-y |
scroll viewport down / up one line (cursor follows only if it leaves the viewport) |
zz / zt / zb |
scroll cursor to viewport center / top / bottom (without moving the cursor) |
| Key | Action |
|---|---|
i |
enter insert mode before the cursor |
a |
enter insert mode after the cursor |
I |
enter insert mode at the first non-blank of the line |
A |
enter insert mode at end of line |
o / O |
open a new line below / above and enter insert mode (auto-indent) |
x |
delete the character after the cursor |
Delete |
delete the character after the cursor |
u / Ctrl-r |
undo / redo |
p / P |
paste after / before the cursor |
v |
enter / exit visual selection (movement keys extend the selection, ESC cancels) |
y |
yank: visual = selection; otherwise y+motion (yy = whole line) |
d |
delete: visual = selection; otherwise d+motion (dd = whole line) |
c |
change: visual = delete selection + insert; otherwise c+motion (cc = whole line, cw/c$/c%) |
C |
change to end of line (same as c$) |
D |
delete to end of line (same as d$) |
% |
jump to the matching bracket (press again to jump back) |
= |
format the current buffer with an external formatter |
| Key | Action |
|---|---|
/ / ? |
forward / backward search (incremental; n/N next/previous, ~ toggles ignore-case) |
E |
query-replace (qemacs style, single-key confirmation per match) |
Q / @ |
start/stop recording a keyboard macro / replay the macro |
Ctrl-s |
save |
Ctrl-l |
redraw |
Ctrl-x |
reload configuration (~/.fe/./.fe.fs/key bindings) |
q |
quit (asks for confirmation if there are unsaved changes) |
| Prefix | Second key |
|---|---|
g |
g=start of file, d=go to definition, y=go to type definition, r=references, i=implementation, s=signature, b=jump back |
z |
z=center, t=top, b=bottom |
Ctrl-w |
window commands (see "Multiple windows") |
An unmatched second key cancels the prefix mode.
| Key | Action |
|---|---|
| any printable character | insert it (including q/h/j/k/l/x; multibyte characters) |
Tab |
word completion (inserts a tab if there is no word prefix) |
Backspace / DEL |
delete the character before the cursor |
Delete |
delete the character after the cursor |
Enter |
newline (copies the current line's leading indentation) |
← → ↑ ↓ / Home / End |
movement |
PgUp / PgDn / Ctrl-f / Ctrl-b |
page up / down |
Ctrl-s |
save |
Ctrl-x |
reload configuration |
ESC |
return to command mode |
Press \ to open the async grep picker: each keystroke re-runs rg (falls back to git grep → grep when rg is absent).
Enter: jump to the selected result (to the matched column)Ctrl-g: dump the current results into the*grep*buffer (path:line:prefix highlighted, matched text highlighted in green, case-insensitive); pressEnterinside*grep*to jumpESC: cancel- After
Ctrl-gthe results go into*grep*, where you can keep browsing or usen/N
Ctrl-o: file picker (recursively lists files, filters as you type,Enteropens the selected match,Ctrl-jopens-or-creates the exact typed path)L: buffer picker (filters as you type,Enterswitches,Deletecloses)
Both pickers use fuzzy matching: space-separated terms, AND, sorted by score, with matched characters highlighted in green.
Ctrl-g opens/switches to the *repl* buffer — not a shell, but the editor's own Forth interpreter (same process, same dictionary), so you can directly access fe's words and variables and define new commands with :.
Inside *repl*:
| Key | Action |
|---|---|
| any character | insert Forth source |
Enter |
evaluate the current line; result/error is appended to the buffer |
Tab |
complete a Forth word |
↑ / ↓ |
history replay |
Backspace / DEL |
delete character |
ESC |
return to command mode |
In other buffers (command mode): ! evaluates an expression, Ctrl-j evaluates the current line/selection — results are appended to *repl*.
The REPL also supports locate <word> / view <word> (open the source file centered on the word's definition), where <word> / browse <substring> (multi-result picker), and see <word> (disassembly).
Ctrl-t starts a language server for the current file (gopls / pylsp / rust-analyzer / typescript-language-server / clangd, etc., mapped by file extension).
- Completion: insert-mode
Tabdispatches to LSP (candidate menu with kind icons + detail + live preview, ↑↓ switches and inserts instantly) - Diagnostics: error/warn/info/hint squiggle highlighting
- Navigation:
gddefinition,gytype,grreferences,giimplementation (multi-result picker withpath:line:content);gbjumps back - Hover / signature:
Khover,gssignature
Ctrl-w enters window commands:
| Key | Action |
|---|---|
v / s |
vertical / horizontal split |
w / Ctrl-w |
focus next window |
h / j / k / l |
directional focus |
c |
close the current window |
Each window has its own cursor / viewport / mode (:split semantics).
- Search:
/?incremental search,n/Nrepeat,~toggles ignore-case, matches highlighted in reverse video. - Query-replace (
E): per matchyreplace /nskip /!all /.replace and stop /q/Enter/ESCstop. - Keyboard macros:
Qrecord,@replay (single register, records key sequences).
u undo, Ctrl-r redo. Based on an operation log; consecutive edits of the same kind are merged (typing one word = one undo step).
If there are unsaved changes, q doesn't exit directly; the status line shows:
- press
qagain → discard and quit Ctrl-s→ save and quit- any other key → cancel
\ ~/.fe example
4 tab-width ! \ tab width 4
0 linenr-default ! \ turn off line numbers by default
char Z ' cmd-qrep bind-command \ rebind query-replace to ZExcept for dependency-related code, all code in this project is released into the public domain (see LICENSE, the Unlicense text).
Dependencies and third-party components (each under its own license): Gforth, rg/git/grep, the various LSP servers, external formatters (gofmt/rustfmt/prettier/clang-format, etc.), and vendor/cJSON (see vendor/cJSON-LICENSE).



