Skip to content
rajangdavisPublic

About

A vibemaxxed TUI code editor with base(d) Sublime Text/VSCode keybindings

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

raj

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.

Basic usage

Build and run

$ 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

Keybindings

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.

Drive it from an agent

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.

Reviewing proposals

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.

Phone and remote screens

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.

Layout

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.

Sessions

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.

Saving

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.

One-file editing as $EDITOR

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'

Control socket

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.

Over TCP, for a driver in a container

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.

Attached clients

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.

Testing

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.

Documents

  • docs/TODO.md — open work
  • docs/COMPLETED.md — what works, and what has been fixed
  • docs/BENCHMARKS.md — measured numbers
  • docs/INVESTIGATIONS.md — terminal findings, root causes, decisions
  • docs/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 them
  • docs/KEYBINDINGS.md — every chord, generated from keys.Bindings so it cannot drift. Regenerate with raj --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 --phone profile: 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.

Configuration

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.

Setup

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.

About

A vibemaxxed TUI code editor with base(d) Sublime Text/VSCode keybindings

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages