A low-memory terminal code editor with VS Code / Sublime keybindings, built to be driven by an agent over a control socket rather than by a pane inside the editor. It runs on a forked Ghostty, or on iTerm2 through a dynamic profile.
Very experimental and in progress; the code was generated by AI across many sessions. Use at your own risk.
$ go build ./cmd/raj
$ ./raj # open the workspace in the explorer
$ ./raj file.go # open a file
$ ./raj some/dir # open a directory as the workspace
raj claims chords the terminal would otherwise keep, so install its config:
$ ./raj --config ghostty --install # or ghostty-linux, or iterm2
$ ./raj --keys # the full reference, as markdown
--config without --install prints to stdout. The macOS Go requirement and
the generated-file details are in Setup below.
Control is opt-in. A plain raj serves nothing; pass --control-addr to
serve a Unix socket plus that TCP address, so another process can read and edit
the open buffers:
$ ./raj --control-addr tcp://0.0.0.0:7391 # also prints a token
Agents and scripts use raj ctl:
$ raj ctl buffers # what is open
$ raj ctl read internal/app/app.go # read a buffer (or a closed file)
$ raj ctl screen --until "modal" # the drawn screen; poll for the text
$ raj ctl apply file.go --base N ... # land an edit as a proposal
$ raj ctl save file.go
An agent's edits land as attributed proposals: tinted in the buffer, and never saved until you accept them. The socket, the token and the container setup are under Control socket below.
A proposal is a change set under an agent's tint. Review mode makes the buffer read-only and gives each decision one gesture:
- accept — keep the text and mark it agreed;
- reject — keep the text, but leave it out of the agreed composition;
- clear — hard-purge a rejected set.
The chords are in raj --keys. On a phone, review lives in the action drawer.
raj can host a workspace for other screens to attach to and render themselves:
# host: an editor started with a control listener, or a headless daemon
$ ./raj --control-addr tcp://127.0.0.1:7391
# client: another terminal; --phone selects the phone profile
$ raj --phone # discovers the local socket
$ raj --attach # same, without the phone profile
Over SSH, run raj --phone with no address: it runs on the host and discovers
the socket there. --daemon is a headless host with no editor window, so attach
clients to it only when nothing else is hosting. A client renders locally, owns
its own tabs, and is read-only for now.
internal/keys bytes -> chord -> action, scope-aware, plus SGR mouse
reports. One measured table generates the Ghostty config,
the iTerm2 profile and the keymap.
internal/term raw mode, KKP stack, alt screen, mouse reporting, suspend,
OSC queries.
internal/ui Host seam: cell grid, frame diff, clipping, sanitisation.
NativeHost (real terminal) and FakeHost (headless tests).
internal/piecetable leaf-embedded B-tree from the benchmark harness, plus
per-author stores, Session, ApplyDiff, undo. Fuzzed
against a naive oracle.
internal/view line index (fuzzed vs rescan), columns, viewport, wrapping.
internal/editor file, cursors, motions, actions, render, binary sniffing,
auto-indent and bracket pairing on the keystroke path.
Atomic saves, a stat guard against another writer, and
line-ending/BOM round tripping.
internal/safe cleanups that run when a background goroutine panics, so a
crash off the main thread does not leave the terminal in
the alternate screen with raj's key flags still pushed.
internal/widget inputs, lists, boxes, the focus vocabulary. Caret
placement from a display column is fuzzed against the
renderer's own width function.
internal/tabs open/close/reopen/switch. One layout function serves both
the renderer and the pointer, so a click cannot land on a
tab other than the one drawn under it.
internal/explorer lazy tree, git-status filter.
internal/hidden which files and folders the tree, search and picker skip.
gitignore-shaped patterns; defaults plus a user file and a
per-workspace file.
internal/search literal/regex engine over the tree and the open buffers,
grouped collapsible results. Literal
queries bypass regexp for bytes.Index; case-insensitive ones
fold ASCII first. Checked against the regexp it replaced.
internal/picker quick-open overlay with fuzzy ranking, in two modes:
cmd+p over the workspace, cmd+shift+o over one file.
internal/symbols declarations by leading keyword, per extension. Fuzzed.
internal/problems the workspace-wide diagnostics list. A view over the
store, with no query and no worker of its own.
internal/hover the floating panel for a language server's answer.
Takes no focus and claims one key.
internal/complete word completion from open buffers, ranked. The Source seam
is where a language server plugs in. Ranking refuses only
an empty prefix; when to show a popup unasked is the
caller's policy.
internal/lsp JSON-RPC framing and byte <-> UTF-16 position mapping,
both fuzzed; process lifecycle, cancellation and restart
backoff; document sync, hover, definition and completion.
Tested against a fake server over pipes.
internal/prompt modal dialogs: ask a question, resume on the answer.
Modal to the pointer too: a press outside an open dialog
is swallowed rather than reaching what is behind it.
internal/probe what chords does this terminal actually deliver?
internal/control the control transport, over a Unix socket or TCP: requests
parked for the event thread. Holds no editor types, so it
cannot edit anything itself.
internal/app event loop, focus routing, layout, breakpoints, debug pane.
Also what a control request means, since that is the one
place it is safe to touch the model.
internal/syntax chroma tokens, cached per version, tokenised off-thread.
raj remembers where you left a workspace: which files were open, which was active, the cursor and scroll in each, wrap, the expanded directories in the sidebar, and which pane had focus. It lands you back there instead of in the explorer.
State goes in the XDG state directory, keyed by workspace (internal/store), so
nothing commits and no repository is required. It is written while raj runs,
from the idle tick and debounced to a few seconds, so a crash costs seconds
rather than the session. --no-restore turns both saving and restoring off.
Buffer contents are not saved. A dirty buffer comes back as the file on disk, because restoring stale text over something edited elsewhere destroys work where a stale cursor position does not; persisting the journal is a separate problem kept in docs/TODO.md. For the same reason a scratch buffer from cmd+n is the one tab that cannot return: it is keyed on a path and has none.
A session is a hint, never an instruction. Every field is re-checked against the world as found — deleted files are dropped, a cursor past the end of a shrunken file is reset, paths outside the workspace are refused — because failing to start over a corrupt scratch file is a far worse outcome than a lost scroll.
A save writes a temp file beside the target, fsyncs it, and renames over it, so an interrupted write leaves the previous file rather than a truncated one. The target's mode is copied onto the new file, so a script stays executable, and symlinks are resolved first, so editing a linked dotfile writes through the link instead of replacing it.
A save is refused if the file changed on disk since raj read or wrote it — a git checkout, a formatter, an agent, a second editor — and you are asked before anything is overwritten. raj compares size and modification time, not a digest, because the check runs on every save.
Line endings and a UTF-8 BOM survive a round trip. The buffer holds LF-only text so that nothing above it — the line index, the wrap engine, every column calculation — has to know an ending can be two bytes; the shape of the file is recorded at open and put back at save. A file with mixed endings cannot be reproduced byte for byte, so raj says which ending it will write rather than quietly rewriting half the lines.
If the file changed and you would rather have the disk version, Reload is the other answer to that prompt, and cmd+shift+r takes it deliberately without going through a save first. It takes what is on disk and ends the buffer's undo history there, because reversals are offsets into a document that no longer exists. The caret keeps its line and column rather than its byte offset, so a file a formatter rewrote still lands you where you were reading.
Buffer contents are still not persisted across a crash; see Sessions above.
raj --standalone FILE is a throwaway single-file editor: no workspace, no
session and no control listener. It roots at the file's own directory without
walking up to an enclosing repository, so a commit message edited from inside a
checkout does not adopt the checkout as its workspace. The sidebar is hidden for
the run, and quitting exits. It takes exactly one file argument — a directory or
an empty argument is refused — and cannot be combined with --daemon,
--attach or --phone.
Configure it as the editor git and other tools launch:
$ git config --global core.editor 'raj --standalone'
An editor serves a control socket only when asked: --control-addr or
--daemon turns it on, and the socket is where another process can read and
edit the open buffers. This is instead of an agent pane: the driver is a
separate
process that can be restarted, replaced or written in another language, and a
crash in it is not a crash in the thing holding your unsaved work.
Length-prefixed frames, a JSON header and a raw body — so document bytes cross
unencoded and a frame is still readable in a dump. Ops: ping, buffers,
open, text, version, apply, save, search, exec, groups,
accept, reject, claim, delete, rename, proposals, diff,
review, lsp, hello, cancel, recv, snapshot, watch. The path defaults to
$XDG_RUNTIME_DIR/raj/<pid>.sock and is printed on stderr at startup.
RAJ_CONTROL_SOCKET puts it somewhere you choose; both the server bind and a
discovering client read it. raj ctl is the command-line client.
recv is the one op that goes the other way. The protocol has no push, so it
is an ordinary request that parks until the user has something to say, and
raj ctl recv blocks until they do. Messages are addressed to a participant
rather than to a connection, so one sent while a driver was restarting is
delivered when it comes back.
raj ctl run --prog BYTES sends a program instead: a byte string of opcodes,
run in order in one frame. Arguments are ops below 0x80 and verbs at or above
it, so a reader that predates an opcode still knows from the byte whether to
skip it or refuse the program — an argument it does not know costs precision, a
verb it does not know would cost correctness. Path, base and author persist
across verbs, so fifty splices into one file state them once.
The framing is MIDI SysEx's, including the property that makes SysEx passable
anywhere: no byte of it is ever zero. SysEx buys that with 7-bit data, because
MIDI reserves the high bit for status; here it is bought with a bias, so every
length and number is a varint of the value plus one and full eight-bit payloads
are kept. That is what lets a whole program travel as an ordinary command-line
argument — argv carries every byte except zero — so --prog takes the bytes
themselves, with @FILE and - for a file or stdin.
Varints replaced fixed-width lengths to get this, giving up the symmetry with the piece table's flat records. The records are a storage format read by offset arithmetic, where fixed width is what makes the arithmetic work; a program is a stream read in order, where it buys nothing. They agreed by taste rather than by need.
apply requires the base version it was written against. Hunks are rebased
onto the current buffer and rejected individually, so an edit written against a
version the user has since typed over is reported as a conflict rather than
applied at an offset that has moved. Edits arriving here are attributed to the
agent, not the user: they carry the agent tint and the user's undo does not
swallow them.
Rejecting a change set is a decision, not an edit. raj ctl reject marks the
set rejected and it leaves the agreed composition, but the text stays in the
document under the rejected tint; raj ctl clear is the hard purge, reversing
the set out of the document and dropping the decision, after which the same
text can be proposed again. So redoing work that came back rejected is
reject, clear, then apply once more. raj ctl open --create is the way to
name a path that is not on disk yet: without it open reaches only a buffer or
file that already exists, and a path that is neither is refused as a typo
rather than made into an empty buffer for a misspelled name to become later.
Authorisation is the socket's file mode, so anything running as you can drive the editor — the same trust boundary as your shell. The TCP listener below is additive.
A daemon serves the same loopback port, tcp://127.0.0.1:7391, alongside its
own per-process socket, and an editor serves that port too once
--control-addr asks for it. Two on one machine collide: the second fails to
bind the port rather than taking another, because the address a client was told
is the address it must reach — pass --control-addr tcp://127.0.0.1:<other> to
run a second.
The socket assumes both ends share a filesystem. Increasingly they do not: raj runs in the terminal on the host, because that is where a terminal actually delivers the chords — a raj inside a container is a raj whose keybindings never arrive — and the agent runs in a container, because that is where it is safe to let it run commands. A Unix socket cannot bridge that: it is a filesystem object, and bind-mounting one does not forward it.
$ raj --control-addr tcp://127.0.0.1:7391 # socket plus the named port
raj: control /run/user/1000/raj/4821.sock
raj: control tcp://127.0.0.1:7391
raj: RAJ_CONTROL_TOKEN=3f9c...
$ raj --control-addr tcp://0.0.0.0:7391 # widen the port to the network
raj: control /run/user/1000/raj/4821.sock
raj: control tcp://0.0.0.0:7391
raj: RAJ_CONTROL_TOKEN=3f9c...
The address is now additive: the Unix socket keeps listening alongside the
port, so a local script can read the token back with raj ctl token instead of
you copying it from startup output.
$ docker run -e RAJ_CONTROL_ADDR=tcp://host.docker.internal:7391 \
-e RAJ_CONTROL_TOKEN=3f9c... \
-v "$PWD:/workspace" -w /workspace your-agent-image
Two things change, and both of them are about the boundary rather than about the protocol.
A token, because the filesystem is no longer doing the authorising. A TCP
listener mints one at startup and prints it, or takes RAJ_CONTROL_TOKEN from
its own environment if you would rather pin it; every request carries it and
anything else is refused and hung up on. 127.0.0.1 is not reachable from a
container, so 0.0.0.0 is usually what you want and raj says so when you take
one — the token is the only thing between that port and your unsaved work.
Paths are translated. /workspace/main.go in the container is
/Users/you/src/proj/main.go to the editor, so raj ctl rewrites paths
outbound and back again: what search prints is something the agent can open
with its own tools. The mapping is inferred — the editor's root does not exist
on the container's filesystem, so the container's own workspace root stands in
for it — and RAJ_ROOT_MAP=/workspace=/Users/you/src/proj overrides that for a
mount layout the inference gets wrong. Nothing is ever inferred over a Unix
socket: reaching one is proof the filesystem is shared.
exec is refused over TCP. The command would run in the editor's process, on
the host, outside the container that was the reason for the container — an
agent that wants to run tests should run them with its own shell, in its own
sandbox.
raj --attach joins a running raj — an editor exposing its control socket, or
a headless --daemon — and renders that workspace locally. --phone implies
--attach and selects the phone profile. The host owns the documents; each
client owns its view.
A client asks the host for a whole-session snapshot of the buffers it shows,
rebuilds each one without reading local disk, and arms a watch that parks
until those buffers move, then re-fetches only what changed. Two control
connections are used on purpose: the watch parks one, so accept/reject/clear
travel on a second and the event thread never waits behind a park.
Tabs are per client. A client mirrors the host's open tabs and keeps mirroring: a tab the host opens after the attach appears on the client, and one the host closes is dropped. A client also takes on a host buffer with a pending change set, so a proposal in a file the client never opened is visible. Files the client opens itself stay off the other screens: opening a path loads it headlessly in the host (no tab there) and installs it locally.
Closing a client tab is a snooze, not a mute: it stays closed while the host buffer is unchanged and comes back when a new edit, proposal or decision moves it. The closed marks are saved with the per-client view, so a reattach keeps them.
Snapshot buffers are read-only. A plain save refuses (only save-as writes), and
every edit gate is refused, so a client that leaves Review still cannot type a
local change the next wake would discard. The bottom bar carries a connection
dot and the tab count; on the phone the action drawer is the control surface —
open with ↑, close with ↓, select or cycle tabs with ←/→, run with
tab.
make check is the gate: fmt, vet, build, go test ./... and the race
detector, cheapest failure first. CI runs the same target, so the thing that
blocks a merge is the thing you can run before pushing.
make smoke is separate and slower. It builds the binary and drives it in a
real process on a real pty, sending the CSI-u sequences internal/keys says
the terminal emits and reading the result back over the control socket — so a
scenario reads as "press this, then the buffer holds that" without parsing a
rendered screen. That covers the seam every other test stubs: terminal setup,
decoding actual bytes, and whether the binary as shipped does what the packages
severally claim.
It is behind a build tag, so go test ./... cannot pick it up by accident, and
it is not in check — it spawns processes and waits on wall-clock time, which
makes it a pre-tag gate rather than a per-commit one. internal/smoke/spec_test.go
is the behavioural spec: saving, the conflict prompt, reload, and the
open-and-save byte round trip.
docs/TODO.md— open workdocs/COMPLETED.md— what works, and what has been fixeddocs/BENCHMARKS.md— measured numbersdocs/INVESTIGATIONS.md— terminal findings, root causes, decisionsdocs/archive/HARNESS-BROKER-AGENT.md— how an agent should author changes against the live buffer. Design record; the control socket is a partial step toward it.docs/archive/CURSOR-VIEWPORT-SPEC.md— the position invariants, and the properties that assert themdocs/KEYBINDINGS.md— every chord, generated fromkeys.Bindingsso it cannot drift. Regenerate withraj --keys > docs/KEYBINDINGS.md; a test fails if the checked-in copy is stale.docs/archive/ATTACH-DESIGN.md— the workspace host and attach design: the daemon, the local-render client, and what remains.docs/MOBILE-REVIEW-SPEC.md— the--phoneprofile: touch tabs, transient status, a review bar, ctrl aliases for super.docs/ARCHITECTURE.md— what raj is made of, and what each thing is called.
Which files the tree, quick-open and search will look at is one shared policy.
The defaults hide version-control metadata and dependency caches — .git/,
node_modules/, vendor/, __pycache__/ and friends — and nothing else.
Dotfiles are visible: .gitlab-ci.yml and .github/ are files you edit.
To change it, write patterns to ~/.config/raj/hidden for every workspace, or
to the workspace's own file at
$XDG_CONFIG_HOME/raj/workspaces/<workspace>/hidden ($XDG_CONFIG_HOME
defaults to ~/.config). The workspace file is keyed by the workspace's roots,
so one workspace has one file rather than one per root. Defaults first, then the
user file, then the workspace one; the last pattern that matches decides.
# ~/.config/raj/hidden
dist/ # a directory, anywhere in the tree
*.log # a glob, matched against the name
/target/ # anchored at the workspace root
build/generated # a path, relative to the root
!vendor/ # un-hide something the defaults hide
A bad line is skipped and reported in the status bar rather than being fatal.
Language servers are configured the same way, in a file called lsp beside
hidden: ~/.config/raj/lsp for every workspace, or
$XDG_CONFIG_HOME/raj/workspaces/<workspace>/lsp for one. Each line names a
language id raj already derives from the file extension and the command to run
for it; a leading ! disables the language. The workspace file lives in XDG,
never in the checkout, so cloning a repository cannot name a program raj runs.
# ~/.config/raj/lsp
go gopls
python pylsp --check-parent-process
!typescript # no server for TypeScript
match *.kt kotlin # a .kt file is the kotlin language
match Gemfile ruby # by base name, or a glob
The command and its arguments are split on whitespace and there is no quoting,
so an argument cannot contain a space; the argv is run directly, never through
a shell. The built-in extension map and command table are the default, so an
empty file changes nothing, and the in-editor lsp.<language>.command/.args
settings override this file. A bad line is skipped and reported in the status
bar, naming the file it came from.
A match <pattern> <language-id> line gives a file type the built-in map does
not know a language, which a command line above can then serve: with match *.kt kotlin and kotlin kotlin-language-server, a .kt file reaches that
server. The pattern is matched against the file base name with the shell-style
*, ? and [...] of path.Match — there is no **, no brace expansion
and no quoting — and the last matching rule wins, so a workspace rule overrides
a user rule. Directory globs are not supported: a pattern containing / is
reported as a bad line rather than silently ignored.
Indentation is not configured. It is read from the file, because one setting is
wrong for any session that has both a Makefile and a JSON file open. In order:
what the format requires (a Makefile is tabs, whatever it currently contains),
then what the file already does, then the language's convention, then --tab N
and --tabs. Those two flags therefore only decide new and un-indented buffers.
You can build the cli with
$ go build ./cmd/raj
On macOS, use Go 1.27.1 or newer. Older toolchains omit LC_UUID when linking
internally, and macOS identifies a binary by that UUID when it remembers a
granted permission — so on macOS 15 and later the local-network access a
non-loopback --control-addr listener needs is asked for again every launch
and never sticks. The symptom looks like a networking bug rather than a build one, which
is what makes it worth stating here. dwarfdump -u ./raj should print a UUID;
if it prints nothing, the toolchain is too old. Building with
-ldflags="-linkmode=external" works around it on any version, and
CGO_ENABLED=0 makes it worse rather than better, since that forces the
internal linking that drops the UUID in the first place.
And call it with:
$ ./raj <file|directory>
Terminal configuration, since raj claims chords the terminal would otherwise take:
$ ./raj --config ghostty --install # or ghostty-linux, or iterm2
$ ./raj --keys # the keybinding reference, as markdown
--install writes the file where the terminal reads it and prints where it
went. Without it, --config prints to stdout, which still works if you would
rather place the file yourself.
For Ghostty this writes raj.conf next to your own config rather than into it,
so raj can never overwrite a setting it did not write. Include it once:
config-file = ~/.config/ghostty/raj.conf
--install prints that line if it is not there yet, and reloading Ghostty with
cmd+shift+, picks up a change. For iTerm2 it writes a dynamic profile, which
iTerm2 loads without a restart. If either file lives somewhere raj would not
guess — a custom iTerm2 settings folder, say — RAJ_GHOSTTY_CONF and
RAJ_ITERM2_PROFILE_PATH name it.
Both generated files carry a hash of the binding table, and raj checks it at startup: when the table has changed and the installed config has not, the status line says so. That matters because a stale config fails invisibly — the chord reaches the terminal, the terminal does what it always did, and raj never hears about it.