$ atty

A Suckless-style PTY proxy in Zig.
Composed at compile time, not loaded at run time.

~/code/atty
$ git checkout feature/auth-refactor
# ↑ atuin module surfaces your last matching command as dim ghost text
 
$ rm -rf /home/work/
! atty guardrail: rm -rf on a root-ish path
line: rm -rf /home/work/
press Enter again to confirm, any other key to cancel.

▸ install architecture write a module

Why

Most extensible terminals load plugins from disk: shared libraries, WASM blobs, scripts. That gives you flexibility but burns startup latency, smears the type system, and turns config bugs into 2 AM mysteries.

atty does the opposite. Modules are Zig types composed at compile time. The dispatch loop is one inline for over your config tuple — disabled modules don’t ship as dead code, they don’t ship at all.

inline for (config.modules) |M| {
    if (comptime @hasDecl(M, "onInput")) {
        switch (try M.onInput(rt, ctx, input)) {
            .forward => {},
            .swallow => return .swallow,
            .replace => |b| current = b,
        }
    }
}

That’s the entire hot path. No vtable. No *anyopaque. No runtime branching on the module list. Disable Atuin and every byte of its worker-thread plumbing vanishes from the binary.

What’s in the box

Quickstart

One-line install — pick your philosophy

# 🛠  Suckless way — clone source, edit config, compile.
curl -fsSL https://get.atty.sh | sh

# 📦 Pre-built binary, default modules.
curl -fsSL https://bin.atty.sh | sh
Path What it does
get.atty.sh Bootstraps Zig if missing → clones to ~/.local/share/atty/src → prompts to edit src/config.zig → builds → installs
bin.atty.sh Detects arch → downloads release asset → sha256 verify → installs

Both end up at ~/.local/bin/atty by default; pass INSTALL_DIR=… to override. The source installer also honors ATTY_SRC=…, ATTY_NONINTERACTIVE=1, and REPO_URL=… so you can fork and self-host.

Or via Docker

git clone https://github.com/fentas/atty
cd atty
./scripts/install.sh        # → ./dist/atty

With Zig

mise use zig@0.16.0          # or any other way to install Zig 0.16
zig build                    # → ./zig-out/bin/atty
zig build test               # 33 unit tests
zig build itest              # PTY integration test

Make it your shell launcher

Two paths — pick whichever matches how you already invoke shells:

Terminal-emulator side. Ghostty (~/.config/ghostty/config):

# Ghostty starts atty, which then starts your shell.
command = atty bash

Prefer the explicit form (atty bash/atty zsh/…) over relying on $SHELL — when the terminal emulator spawns atty directly, the environment is minimal and $SHELL may not yet be set.

Shell-rc side. If you can’t (or don’t want to) touch your terminal config — same machine but multiple terminal emulators, remote SSH, dotfiles you share across boxes — drop this in your .bashrc / .zshrc:

eval "$(atty init bash)"   # or `atty init zsh`

The snippet re-execs the current interactive shell under atty once (atty injects ATTY=1 / ATTY_PID / ATTY_VERSION into the child env so nested invocations short-circuit) and wires the shell-side OSC 133 prompt markers so atty can capture the input region precisely instead of falling back to keystroke tracking. Non-interactive shells (scripts, bash -c, ssh without TTY) skip the snippet entirely.

Or invoke ad-hoc:

atty                         # spawns $SHELL through the proxy
atty --shell /bin/bash       # different shell
atty -- -c 'echo hi'         # passthrough args

Configuration

dwm-style two-file split:

Edit src/config.zig. Recompile. Your edits never conflict on git pull because the file isn’t tracked, and your config only contains what you override — every other knob falls through to defaults.zig, so new tunables added upstream just appear without you touching anything.

const atty = @import("atty");

// Pick your modules. Default = { guardrail, history } — dependency-free.
pub const modules = .{
    atty.modules.guardrail.configure(.{}),
    atty.modules.atuin.configure(.{
        .suggestion_ttl_ms  = 0,          // 0 = fish-style (no fade)
        .sync_after_records = 10,
    }),
    atty.modules.history.configure(.{}),  // shell-native fallback
};

// Override the visual style if you don't want the dim-only default.
pub const ghost: atty.Ghost = .{ .style = atty.style.presets.muted_italic };

// Override the accept keys if Right / End / Ctrl+F isn't what you want.
pub const keymap: atty.Keymap = .{
    .bindings = &.{
        .{ .bytes = atty.keymap.key("Tab"), .action = .ghost_accept },
    },
};

Every subsystem (proxy, ghost, terminal, keymap, statusbar) is a struct with per-field defaults — your pub const xxx: atty.Xxx = .{ … } only spells out the fields you want different. Anything you don’t declare picks up defaults.zig, and new fields added upstream flow through automatically.

Track your config outside the repo: -Dconfig=/path/to/mine.zig (or make CONFIG=/path/to/mine.zig build).

Read on

Status

v0.1 — unit tests, integration test, e2e scenario harness with visual grid diff, all green. PTY core production-ready; Atuin subprocess backend records and syncs today; daemon socket stub waiting on upstream IPC stabilisation. MIT-licensed. Bugs welcome at github.com/fentas/atty.