Writing a module
- Shared types
- Minimal example — Upper
- Lifecycle
- Hot-path rules
- Action semantics
- statusText — contributing to the status bar
- provideHintText / provideErrorText — notifications above the status bar
- provideTermBytes — write to the user’s outer terminal
- pollShellInput — inject bytes into the shell asynchronously
- provideGhostList — contributing to the multi-row pick list
- deleteHistoryMatch — react to the user’s “delete this line” key
- ctx.incognito — opt into stricter behavior
- Order matters
- Testing
- Verifying dead-code elimination
A module is a Zig type — typically produced by a
configure(comptime cfg: Config) type factory — that exposes some
subset of:
pub const name: []const u8 // optional, for logs
pub const Runtime : type
pub fn attach (allocator, io) !Runtime
pub fn detach (rt: *Runtime, io) void
pub fn onInput (rt: *Runtime, ctx: *Context, input: []const u8) !Action
pub fn onOutput (rt: *Runtime, ctx: *Context, output: []const u8) !void
pub fn onTick (rt: *Runtime, ctx: *Context, elapsed_ms: u64) !void
pub fn onLineCommit(rt: *Runtime, ctx: *Context, line: []const u8) !void
pub fn deleteHistoryMatch(rt: *Runtime, ctx: *Context, line: []const u8) !void
pub fn provideGhostText(rt: *Runtime, ctx: *Context) !?[]const u8
pub fn provideGhostList(rt: *Runtime, ctx: *Context) !?[]const []const u8
pub fn pollShellInput(rt: *Runtime, ctx: *Context) !?[]const u8
pub fn provideHintText(rt: *Runtime, ctx: *Context) !?[]const u8
pub fn provideErrorText(rt: *Runtime, ctx: *Context) !?[]const u8
pub fn provideTermBytes(rt: *Runtime, ctx: *Context) !?[]const u8
pub fn statusText(rt: *Runtime, ctx: *Context) !?[]const u8
The framework introspects each module via @hasDecl at comptime —
missing hooks are statically eliminated from the dispatch loop, not
merely skipped at runtime.
Shared types
pub const Action = union(enum) {
forward, // pass bytes through unchanged
swallow, // drop bytes entirely (short-circuits)
replace: []const u8, // substitute these bytes
};
pub const Context = struct {
allocator: std.mem.Allocator,
line: *LineState, // current user input buffer + uncertain flag
scratch: *std.ArrayList(u8),
is_tty: bool,
};
pub const Error = error{ ModuleFailed, OutOfMemory };
Minimal example — Upper
Capitalise every keystroke before it reaches the shell:
const std = @import("std");
const m = @import("../module.zig");
pub const Config = struct {};
pub fn configure(comptime _: Config) type {
return struct {
pub const name = "upper";
pub const Runtime = struct {
buf: [256]u8 = undefined,
};
pub fn attach(_: std.mem.Allocator) !Runtime { return .{}; }
pub fn detach(_: *Runtime) void {}
pub fn onInput(
rt: *Runtime,
_: *m.Context,
input: []const u8,
) m.Error!m.Action {
if (input.len > rt.buf.len) return .forward;
for (input, 0..) |b, i| rt.buf[i] = std.ascii.toUpper(b);
return .{ .replace = rt.buf[0..input.len] };
}
};
}
Then in src/config.zig:
const atty = @import("atty");
pub const Upper = @import("modules/upper.zig").configure(.{});
// or if you've placed it inside src/modules/:
// atty.modules.upper.configure(.{})
pub const modules = .{
atty.modules.guardrail.configure(.{}),
Upper,
};
Lifecycle
attach(allocator)returns the initial Runtime value. Spawn worker threads, open sockets, etc. here. The framework heap-allocates the Runtime — you don’t need to.detach(rt)is called once at shutdown. Signal worker threads to stop, join them, close sockets.
Allocation: each module’s Runtime is heap-allocated by attachAll
and freed by detachAll. Modules don’t manage their own Runtime
allocation.
Hot-path rules
onInput runs on every single keystroke (~100 Hz worst case).
Therefore:
- No allocations. Use a fixed-size buffer in your Runtime, or the
per-dispatch
ctx.scratch. - No blocking I/O. If you need a network/socket lookup, do it on a
worker thread and expose the result via
provideGhostText. - No global locks. Per-Runtime mutexes are fine.
provideGhostText runs whenever the proxy needs to refresh the
overlay (after a keystroke, after shell output, on tick). It runs
after onInput for that event, so ctx.line.current() reflects
the post-input buffer.
onTick fires on poll() timeout (default 100 ms). Use it for
periodic work — TTL expiry, status updates. Don’t do heavy work
here; ticks are not throttled.
onLineCommit fires once per Enter-press, after the line buffer has
been cleared, with the pre-Enter line as its argument. Use it for
history recording, audit logs, telemetry. The hook does not fire
when the line was empty or when line_state.uncertain was true at
submit time — recording a wrong line is worse than missing one, so
the proxy skips uncertain commits. Don’t block here; spawn a worker
or detach a thread if your work involves I/O.
Action semantics
| Returned action | What happens |
|---|---|
.forward |
Bytes flow on to the next module. |
.swallow |
Chain stops, nothing is written to the PTY. |
.replace |
Next modules see the new bytes; PTY writes those. |
.replace_commit |
Same as .replace, AND fires onLineCommit on the pre-replace line when the original input contained Enter (see below). |
If a module returns .replace, downstream modules see the replaced
bytes, not the original. This composes guardrail with autosuggestion:
guardrail can swallow Enter, and Atuin never sees the keystroke that
would have submitted a dangerous command.
.replace_commit exists for modules that intercept Enter to do
something custom but still want the typed line to land in history.
The LLM module uses it: typing #: list files + Enter returns
.replace_commit = "\x15" — Ctrl+U kills the readline buffer so
the shell doesn’t run the prompt, but the proxy fires
dispatchLineCommit on #: list files so atuin / history record
it. The next time the user starts typing #: l… their prior
prompts surface as ghost suggestions.
Important caveat: .replace_commit does NOT unconditionally
fire onLineCommit. The proxy still requires the original input
chunk (the bytes the user just typed, pre-replace) to contain
Enter — and the usual gating around incognito mode, leading-space
HISTCONTROL convention, and committed_was_uncertain still
applies. Returning .replace_commit on a non-Enter keystroke is a
no-op as far as history is concerned; the only thing it changes
vs. plain .replace is that the Enter test looks at the original
bytes instead of the replacement.
The dispatcher preserves .replace_commit across later modules’
plain .replace substitutions — once a module asks for the commit
to fire, the decision sticks (only the bytes get overwritten).
statusText — contributing to the status bar
Modules can advertise a one-line segment to the bottom status bar by
implementing the optional statusText hook:
pub fn statusText(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8 {
if (rt.queued_records > 0) {
// Cache in your Runtime; this runs on every render cycle.
return rt.cached_status_line;
}
return null; // null = no segment this frame
}
The proxy collects every module’s segment, joins them with ` │ ,
prefixes the optional config.statusbar.base_text, and paints the
result into the reserved row. Returning null` (or an empty string)
skips the segment cleanly — separators stay correct.
Segment order = module declaration order. There’s no claim on columns; the assembled text is left-aligned in the row and silently truncated past 256 bytes. If you want per-module max-width, format your own segment with a cap.
Don’t allocate in statusText — it runs every render cycle. Cache
the formatted string in your Runtime.
provideHintText / provideErrorText — notifications above the status bar
When the statusbar is enabled with reserve_rows >= 2, the row
above the status text is a transient notification slot. Two
parallel hooks fill it, each with its own visual treatment and
TTL:
pub fn provideHintText(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8;
pub fn provideErrorText(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8;
- Hints (
provideHintText) render inconfig.statusbar.hint_style— dim italic by default. Used for informational annotations the user should see briefly (LLM explanation of the injected command, guardrail “blocked: …” rationale, …). - Errors (
provideErrorText) render inconfig.statusbar.error_style— dim red by default, with a leading⚠glyph so the row reads as a notification rather than info text. Take precedence over hints on the same row.
Both are one-shot: return the text once on an edge change,
return null thereafter. The proxy hands the result to
StatusBar.setHint / setError which manage TTL from there
(config.statusbar.hint_ttl_ms defaults to 30s; error_ttl_ms
defaults to 60s). After expiry the slot reverts — a still-active
suppressed hint resurfaces once its error sibling expires.
Typical pattern:
pub const Runtime = struct {
pending_hint_buf: [256]u8 = undefined,
pending_hint_len: usize = 0,
pending_hint: bool = false,
};
// Set the pending flag from wherever your data arrives — worker
// thread, onLineCommit, an external callback.
fn latchHint(rt: *Runtime, msg: []const u8) void {
const n = @min(msg.len, rt.pending_hint_buf.len);
@memcpy(rt.pending_hint_buf[0..n], msg[0..n]);
rt.pending_hint_len = n;
rt.pending_hint = true;
}
pub fn provideHintText(rt: *Runtime, _: *m.Context) m.Error!?[]const u8 {
if (!rt.pending_hint) return null;
rt.pending_hint = false;
return rt.pending_hint_buf[0..rt.pending_hint_len];
}
provideTermBytes — write to the user’s outer terminal
pollShellInput writes to the pty.master (the shell sees the
bytes); provideTermBytes writes to the user’s outer stdout
(only the user’s terminal sees the bytes — the shell is unaware).
Used for OSC sequences that affect the terminal as a whole:
cursor colour transitions, title updates, palette hints.
pub fn provideTermBytes(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8;
Same one-shot contract as provideHintText. The proxy calls per
tick; if non-null, the returned bytes are written to
STDOUT_FILENO. The bytes should be edge-triggered — a module
that returns the same OSC sequence on every tick will flood the
terminal with redundant escapes.
Example — emit OSC 12 (set cursor colour) on a state transition:
pub fn provideTermBytes(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8 {
const matches = std.mem.startsWith(u8, ctx.line.current(), "#: ");
if (matches and !rt.cursor_signal_active) {
rt.cursor_signal_active = true;
return "\x1B]12;cyan\x07";
}
if (!matches and rt.cursor_signal_active) {
rt.cursor_signal_active = false;
return "\x1B]112\x07"; // reset to default
}
return null;
}
Don’t allocate here — it runs every tick. Static literals or fixed buffers on the Runtime only.
pollShellInput — inject bytes into the shell asynchronously
Sibling of provideTermBytes for the shell-input direction.
Returned bytes go to pty.master as if the user had typed them —
the shell echoes them back, readline lets the user edit or
submit. Used by the LLM module to inject a generated command for
review after the worker thread returns a response.
pub fn pollShellInput(rt: *Runtime, ctx: *m.Context) m.Error!?[]const u8;
The proxy also runs line_state.applyInput on the injected
bytes, so downstream onInput / onLineCommit see the injected
command as if the user had typed it.
Same one-shot pattern — return bytes once when ready, return
null thereafter. First non-null wins across modules (order =
declaration order in config.modules).
provideGhostList — contributing to the multi-row pick list
Companion to provideGhostText. Where provideGhostText returns one
suggestion (painted after the cursor), provideGhostList returns up
to N alternatives painted in dim rows below the prompt as a numbered
list:
$ git status ← inline ghost (newest match)
1: git push origin master
2: git commit -m foo
3: git log
The proxy paints this list dynamically when config.ghost.list_count > 0
and the user is typing a non-empty, non-uncertain line. Key bindings
Ctrl+1..Ctrl+9 (kitty kbd) and Esc+1..Esc+9 (legacy fallback)
pick the Nth entry — same substitution semantics as ghost_accept.
pub fn provideGhostList(rt: *Runtime, ctx: *m.Context) m.Error!?[]const []const u8 {
if (ctx.line.uncertain) return null;
const query = ctx.line.current();
if (query.len == 0) return null;
// Build up to N matches via the shared `_lib.ListBuilder` —
// dedupes by content + skips the inline-ghost entry so the list
// complements rather than duplicates the inline ghost.
var builder = lib.ListBuilder(9){};
const inline_match = findInlineSuggestion(rt, query); // your own
for (yourCandidates(rt, query)) |entry| {
if (builder.full()) break;
_ = builder.tryAdd(entry, inline_match);
}
if (builder.len == 0) return null;
// Spill into your Runtime's persistent storage — the slice we
// return must outlive the local builder frame.
@memcpy(rt.list_slices[0..builder.len], builder.items());
rt.list_slices_len = builder.len;
return rt.list_slices[0..rt.list_slices_len];
}
The same “first non-null wins” precedence model as provideGhostText
applies — order modules in your config to express priority.
src/modules/_lib.zig provides nowMs() + ListBuilder(cap) —
shared helpers with the dedup-on-add pattern both built-in modules
use. Reach for them rather than re-rolling.
deleteHistoryMatch — react to the user’s “delete this line” key
When the user fires Action.delete_history_match (default Ctrl+Shift+D),
the proxy calls every module’s deleteHistoryMatch with the current
line buffer content. Modules that store entries by content (history)
implement this; modules that store entries by ID without exposing a
content-based delete (atuin) skip it.
pub fn deleteHistoryMatch(rt: *Runtime, ctx: *m.Context, line: []const u8) m.Error!void {
// Remove every entry in your store whose content equals `line`.
// `line` is non-empty and the line state was certain when the
// user pressed the key — the proxy gates these conditions.
...
}
After dispatching, the proxy sends \x15 (Ctrl+U) to the shell so
the prompt clears, and flashes a transient message in the status bar.
ctx.incognito — opt into stricter behavior
The proxy gates dispatchLineCommit when the user has flipped
incognito on (or when the line starts with a space —
HISTCONTROL=ignorespace convention), so atuin / history never see
the commit and don’t record. Ghost suggestions and queries keep
working — incognito is about not recording, not about hiding.
If your module wants to be stricter — e.g. suppress its own
suggestions while typing a secret — read ctx.incognito and
short-circuit. The default is “still suggest”.
Order matters
The dispatcher walks config.modules front-to-back. Put
short-circuiting modules (guardrail) first; passive ones (Atuin) last.
For provideGhostText the first non-null result wins, so order
expresses priority. There’s no negotiation between modules — a later
module is asked only if every earlier one returned null.
Composing two ghost providers
With both atuin and history enabled:
pub const modules = .{
atty.modules.guardrail.configure(.{}),
atty.modules.atuin.configure(.{}), // asked first
atty.modules.history.configure(.{}), // fallback when atuin is null
};
Per keystroke, the proxy calls gatherGhostText once. It iterates
front-to-back and returns the first non-null:
inline for (modules, 0..) |M, i| {
if (comptime @hasDecl(M, "provideGhostText")) {
if (try M.provideGhostText(rts[i], ctx)) |text| return text;
}
}
The async/sync subtlety. Atuin’s provideGhostText reads a
worker-thread mailbox (res_buf). On the very first keystroke of a
new prefix the mailbox is still empty — atuin returns null while its
worker shells out to atuin search. During that ~50 ms window the
fallback (history, which is synchronous in-memory) gets to serve the
suggestion. Once atuin’s worker finishes writing res_buf, the next
render swaps in atuin’s answer. If atuin and history disagree, the
overlay briefly switches as you type.
This is a deliberate trade-off, not a bug: it keeps ghost text visible during atuin’s lookup latency. If you’d rather see a single source, remove the fallback or put history first.
Testing
Write tests inline in your module file. They’ll be picked up by
zig build test if you add your file to src/unit_tests.zig.
test "my module swallows the dangerous Enter" {
const M = configure(.{});
var rt = try M.attach(std.testing.allocator);
defer M.detach(&rt);
var line = LineState{};
_ = line.applyInput("rm -rf /");
var scratch = std.ArrayList(u8).init(std.testing.allocator);
defer scratch.deinit();
var ctx = m.Context{
.allocator = std.testing.allocator,
.line = &line,
.scratch = &scratch,
.is_tty = false,
};
try std.testing.expectEqual(m.Action.swallow, try M.onInput(&rt, &ctx, "\r"));
}
See src/modules/guardrail.zig for a worked example with an
injectable output sink so tests don’t write to stderr.
Verifying dead-code elimination
The strong claim is that disabled modules contribute nothing to the binary. Verify it:
# build with all modules
make build
size=$(stat -c %s zig-out/bin/atty)
# remove a module from config.modules, rebuild
$EDITOR src/config.zig
make build
smaller=$(stat -c %s zig-out/bin/atty)
# check symbols
nm zig-out/bin/atty | grep -c atuin # should be 0 after removing Atuin
If a module’s symbols persist after removal, you’ve found a leak
(probably a forgotten @import somewhere) — file an issue.