15 Mar 2026
what is nanoclaw
nanoclaw is a WhatsApp-integrated AI assistant built on Claude that runs in Docker containers with per-group isolation. it’s designed for multi-group conversations with separate memory contexts, scheduled tasks, and persistent message history.
the interesting question isn’t what it does – it’s how it manages conversation context across multiple groups with potentially thousands of messages.
the memory question
when building LLM-based agents, one of the first architectural decisions is: how do you handle conversation history that exceeds the context window?
popular approaches:
- RAG (Retrieval-Augmented Generation): embed messages, store in vector DB, retrieve semantically relevant chunks
- Summarization: periodically summarize old messages, keep summaries in context
- Sliding window: keep last N messages, drop older ones
- Hybrid: combine multiple strategies
nanoclaw uses none of these. let’s see what it actually does.
architecture overview
nanoclaw’s memory system has two primary components:
- SQLite database (
store/messages.db) - stores all messages with metadata - Markdown files (
conversations/ folder) - searchable conversation exports
the database schema is straightforward:
CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, -- WhatsApp group ID sender TEXT NOT NULL, -- Phone number sender_name TEXT, -- Display name content TEXT NOT NULL, -- Message text timestamp TEXT NOT NULL, -- ISO 8601 is_from_me INTEGER DEFAULT 0, -- Bot's own messages is_bot_message INTEGER DEFAULT 0 -- Messages from bot );
source: src/db.ts lines 15-25
no embeddings. no vector columns. no fancy indexing beyond the standard B-tree on chat_jid and timestamp.
the 200-message rolling window
when nanoclaw processes a new message, it retrieves conversation history using getMessagesSince():
export function getMessagesSince( chatJid: string, sinceTimestamp: string, botPrefix: string, limit: number = 200, ): NewMessage[] { const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? ) ORDER BY timestamp `; return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit); }
source: src/db.ts lines 341-364
key observations:
- Hard limit of 200 messages - enforced at the SQL level via
LIMIT ? - Cursor-based pagination - uses
sinceTimestamp instead of fixed time window - Chronological order -
ORDER BY timestamp ensures messages are in conversation order - Filters bot’s own messages - excludes
is_bot_message = 1 to avoid self-references
the 200-message limit is not configurable and not adaptive based on token count. it’s a simple message count cap.
cursor-based retrieval mechanism
the “rolling window” isn’t time-based – it’s cursor-based. here’s how it works:
// src/index.ts lines 158-163 const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); // After processing... // src/index.ts lines 183-184 lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp;
flow:
- Initial state:
lastAgentTimestamp is empty string '' - retrieves last 200 messages from entire history
- After first run: cursor advances to timestamp of last processed message
- Next run: retrieves all messages AFTER that timestamp (up to 200)
- If < 200 new messages: gets all of them
- If > 200 new messages: gets only 200 most recent, older ones are dropped
this means:
- if you send 500 messages while the bot is offline, it only sees the last 200
- the cursor never goes backward
- there’s no “lookback” or “re-retrieval” of older context
accessing context beyond the window
so what happens if you reference something from message #201?
nanoclaw provides manual retrieval tools:
1. conversation folder exports
# conversations/ The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
source: groups/main/CLAUDE.md line 39
agents can use the Read tool to read exported conversation markdown files.
agents can search message content using the Grep tool:
Grep({ pattern: "budget discussion", path: "/workspace/project/conversations/", output_mode: "content" })
3. direct database queries
agents can query the SQLite database directly via Bash tool:
sqlite3 /workspace/project/store/messages.db " SELECT timestamp, sender_name, content FROM messages WHERE chat_jid = '[email protected]' AND content LIKE '%budget%' ORDER BY timestamp DESC LIMIT 10; "
key point: retrieval is manual and tool-initiated. the agent must explicitly decide to search for old context. it’s not automatic like RAG.
comparison to RAG-based systems
| feature | nanoclaw | typical RAG system |
| storage | SQLite (relational) | Vector DB (Pinecone, Chroma, Weaviate) |
| retrieval | manual tool calls | automatic semantic search |
| context selection | chronological (last 200) | semantic similarity top-k |
| embeddings | none | required |
| search | SQL WHERE / Grep | vector similarity (cosine, euclidean) |
| latency | sub-millisecond SQL | depends on vector DB, usually 10-100ms |
| cost | zero (SQLite is free) | vector DB hosting + embedding API calls |
| complexity | low (just SQL) | medium-high (embedding pipeline, vector indexing) |
why this matters:
RAG systems automatically retrieve relevant context based on semantic similarity:
- user asks “what was our budget discussion?”
- system embeds the query
- retrieves top 5 semantically similar messages
- adds them to context
nanoclaw requires the agent to explicitly search:
- agent sees “what was our budget discussion?”
- agent thinks “I need to search for this”
- agent calls
Grep or Read tool - agent adds findings to response
this is more transparent (you see the search happening) but less automatic (agent might forget to search).
trade-offs and design decisions
why 200 messages?
likely a balance between:
- context window limits: keeping token count manageable
- conversation coherence: 200 messages covers most multi-turn conversations
- query performance: SQLite
LIMIT 200 is fast even on large tables
why no embeddings?
embeddings add complexity:
- need embedding API (OpenAI, Cohere, etc.) or local model
- need vector storage and indexing
- need embedding refresh on message updates
- adds latency and cost
for a personal assistant handling dozens of groups, simplicity > sophistication.
why cursor-based?
alternatives:
- time window (last 7 days): breaks if conversation is inactive for a week
- fixed offset (messages 1000-1200): doesn’t adapt to conversation growth
- cursor (since last processed): always picks up where you left off
cursor-based ensures continuity even with irregular message patterns.
why manual retrieval?
automatic RAG retrieval can:
- add irrelevant context (semantic similarity isn’t perfect)
- increase latency (every message triggers vector search)
- use more tokens (retrieved chunks added to every request)
manual retrieval gives the agent control over when to pay the cost of searching.
source citations
all analysis based on nanoclaw repository source code:
- database schema:
src/db.ts lines 15-25 - getMessagesSince function:
src/db.ts lines 341-364 - cursor advancement:
src/index.ts lines 158-163, 183-184 - conversation folder docs:
groups/main/CLAUDE.md line 39 - message filtering: excludes
is_bot_message and bot-prefixed content
bottom line: nanoclaw’s memory is simple by design. no embeddings, no RAG, just SQLite with a 200-message rolling window and manual tool-based search. it trades automatic semantic retrieval for simplicity, transparency, and zero external dependencies.
for a multi-group WhatsApp assistant, that’s probably the right call.
11 Mar 2026
the request
claude code shipped vim mode in late 2025. within days, opencode had two open issues asking for the same thing – #1764 and #11111, the latter with 20+ comments and a fair amount of frustration from people who’d switched from claude code specifically to get an open-source alternative but then had to give up muscle memory every time they opened the prompt.
opencode is exactly that: an open-source alternative to claude code. same agentic AI coding concept, same terminal-first workflow, but the source is public and you can modify it. the TUI is built on SolidJS and @opentui/core, a terminal rendering framework. the prompt input is a TextareaRenderable widget that handles text, cursor position, and syntax highlighting via extmarks.
the goal was exact parity with claude code’s vim feature set. not a subset where we skip the hard parts, not a superset where we add visual mode and count prefixes. whatever claude code supports, we support. whatever it doesn’t, we don’t. that constraint actually made the scope manageable.
what claude code’s vim mode actually supports
the official docs at code.claude.com/docs/en/interactive-mode list the full feature set. two modes only: NORMAL and INSERT. no visual mode, no command mode, no ex commands.
mode switching:
i – insert before cursor I – insert at first non-blank of line a – insert after cursor A – insert at end of line o – open new line below, enter insert mode O – open new line above, enter insert mode Esc – return to normal mode
navigation:
h/j/k/l – left/down/up/right w/e/b – word motions 0/$ – line start/end ^ – first non-blank gg/G – buffer start/end f/F/t/T{char} – character search on current line ;/, – repeat last f/F/t/T forward/backward
editing:
x – delete character dd/D – delete line / delete to end d{motion} – delete with motion cc/C – change line / change to end c{motion} – change with motion yy/Y – yank line y{motion} – yank with motion p/P – paste after/before >>/<< – indent/dedent J – join lines . – repeat last change
text objects (used with d/c/y):
iw/aw – inner/around word iW/aW – inner/around WORD (whitespace-delimited) i"/a" – inner/around double quotes i'/a' – inner/around single quotes i(/a( – inner/around parentheses i[/a[ – inner/around square brackets i{/a{ – inner/around curly braces
explicitly not supported:
- no visual mode (v/V)
- no count prefixes (3j, 5dd)
- no undo/redo (u, Ctrl+R)
- no / search
- no r (replace char in place)
that’s the spec. now let’s look at the codebase we’re modifying.
opencode’s TUI architecture
opencode is a monorepo. the TUI lives in packages/opencode/. the relevant source tree:
packages/opencode/src/ config/ tui-schema.ts # Zod schema for tui.json tui.ts # config loading cli/cmd/tui/ app.tsx # root component, provider tree context/ keybind.tsx # keybind context (existing pattern we follow) vim.tsx # NEW: vim context component/ prompt/ index.tsx # ~1171 lines, the prompt component textarea-keybindings.ts # maps keybinds to KeyBinding[] lib/ vim-engine.ts # NEW: pure TS state machine
if you’re wondering “why is a terminal app using SolidJS” – the JSX you see in opencode’s source isn’t React. it’s the same angle-bracket syntax, but SolidJS compiles it into direct reactive wiring with no virtual DOM and no diffing. the rendering stack is:
SolidJS (reactive state) → @opentui/solid (binding layer) → @opentui/core (Zig-based terminal engine)
SolidJS manages signals and effects. @opentui/solid translates JSX elements like <box> and <textarea> into native renderables backed by @opentui/core, which is written in Zig. bun is just the JavaScript runtime; there’s no browser, no electron, no DOM at any point.
the way Zig ends up rendering to your terminal is through bun’s FFI (foreign function interface). @opentui/core ships a platform-specific shared library (.dylib on macOS, .so on Linux) compiled from Zig source. the TypeScript side calls into it via bun:ffi – you can see this in the type definitions: createRenderer returns a Pointer, render takes a Pointer, everything crosses the FFI boundary as raw pointers and primitives. no serialization, no IPC, just direct function calls into native code.
on the TypeScript side, each renderable (BoxRenderable, TextRenderable, TextareaRenderable) holds a Yoga layout node – that’s Facebook’s cross-platform flexbox engine. when you write <box flexGrow={1} paddingLeft={2}>, Solid creates a renderable with those layout properties set on its Yoga node. the layout tree is computed in JavaScript, but the actual painting happens in Zig.
the render loop looks roughly like this:
- something changes – a signal updates, text is typed, the terminal resizes
- Yoga recomputes the layout tree (which nodes moved, which resized)
- each renderable paints its content into an
OptimizedBuffer – a Zig-allocated character grid where each cell holds a character, foreground color, background color, and text attributes - the Zig renderer diffs the current buffer against the previous frame
- only the cells that actually changed get written to stdout as ANSI escape codes – cursor movement sequences to skip unchanged regions, SGR sequences for colors and styles, then the character data
step 4 is where the performance comes from. a full-screen terminal might be 200x50 = 10,000 cells. if you type one character in the prompt, maybe 20 cells change. the Zig renderer writes escape codes for those 20 cells and skips the other 9,980. this is the same approach that tmux and neovim use – compute the minimal diff, emit the minimal escape sequences.
when a signal like vim.mode changes, only the one <text> node showing -- INSERT -- updates. that update is a direct property mutation on the Zig-backed renderable, which marks those cells dirty for the next paint. nothing else in the tree re-evaluates. this is why SolidJS is a good fit for TUI work: its granular reactivity maps directly to granular terminal updates.
the terminal rendering framework is @opentui/core at v0.1.87. it provides TextareaRenderable, which is the core input widget. the interface you care about:
interface TextareaRenderable { plainText: string; // current text content cursorOffset: number; // get/set cursor position (byte offset) setText(text: string): void; // replace entire content insertText(text: string): void; // insert at cursor clear(): void; extmarks: Extmark[]; // syntax highlighting regions }
key flow for keyboard input: config in tui-schema.ts defines what keybinds are valid, keybind.ts parses them, keybind.tsx provides them via SolidJS context, and textarea-keybindings.ts maps them to KeyBinding[] objects that the textarea widget understands. the prompt component (prompt/index.tsx) has an onKeyDown handler that intercepts keys before they reach the textarea.
the context pattern throughout the codebase uses a createSimpleContext helper:
// from keybind.tsx -- the pattern we follow export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ name: "Keybind", init: () => { // ... setup logic return { /* context value */ }; }, });
createSimpleContext returns a { provider, use } pair. the provider wraps children and makes the context available. use is the hook that components call to access it. we follow this exact pattern for vim.
the implementation of createSimpleContext is straightforward – it’s a thin wrapper around SolidJS’s createContext and useContext:
// simplified from context/common.ts export function createSimpleContext<T>(opts: { name: string init: () => T }) { const Context = createContext<T | undefined>(undefined) function provider(props: { children: JSX.Element }) { const value = opts.init() return <Context.Provider value={value}>{props.children}</Context.Provider> } function use(): T { const ctx = useContext(Context) if (!ctx) throw new Error(`${opts.name} context not found`) return ctx } return { provider, use } }
the provider tree in app.tsx is a nested stack of these providers. order matters – a provider can only call use on contexts that are higher in the tree (already initialized). VimProvider reads from TuiConfigProvider, so it must be nested inside it:
// app.tsx (simplified) export function App() { return ( <TuiConfigProvider> <ThemeProvider> <KeybindProvider> <VimProvider> {" "} {/* reads TuiConfig, provides vim context */} <Prompt /> </VimProvider> </KeybindProvider> </ThemeProvider> </TuiConfigProvider> ); }
the actual tree has more providers, but this shows the nesting relationship. Prompt can call useVim(), useKeybind(), useTheme(), and useTuiConfig() because all four are ancestors in the tree.
the four-layer architecture
the implementation splits across four layers. each layer has a single responsibility and doesn’t reach into the others’ concerns.
layer 1: config
packages/opencode/src/config/tui-schema.ts defines the shape of tui.json. we add one field:
vim: z.boolean() .optional() .describe("Enable vim editing mode in the prompt input");
opt-in via { "vim": true } in .opencode/tui.json. defaults to false (or rather, undefined, which we treat as false).
we used .optional() instead of .default(false) deliberately. the tui.ts loader has several fallback paths that construct empty {} objects and merge them. with .default(false), Zod’s output type inference makes vim non-optional (boolean instead of boolean | undefined), which causes type errors at those empty-object fallback sites. .optional() keeps vim as boolean | undefined throughout, and we handle the ?? false defaulting in the context layer where we actually use it.
layer 2: VimEngine
packages/opencode/src/cli/cmd/tui/lib/vim-engine.ts is 957 lines of pure TypeScript. zero imports. no SolidJS, no @opentui/core, no external dependencies at all.
the public interface:
export type VimMode = "normal" | "insert"; export interface VimKeyEvent { key: string; ctrl: boolean; shift: boolean; meta: boolean; } export interface VimResult { consumed: boolean; newText?: string; newCursor?: number; modeChange?: VimMode; } export class VimEngine { getMode(): VimMode; handleKey(event: VimKeyEvent, text: string, cursor: number): VimResult; reset(): void; }
handleKey takes the current text and cursor position as arguments. it returns what changed. the engine never holds a reference to the textarea – it doesn’t know TextareaRenderable exists. the caller reads the current state, passes it in, and applies the result. this makes the engine testable with plain strings and integers, no UI framework required.
internal state:
private mode: VimMode = "normal" private pending: string = "" // multi-key buffer: "d", "c", "y", "g", ">", "<", "f", "F", "t", "T" private register: string = "" // unnamed yank register private lastChange: ChangeRecord | null = null // for dot repeat private lastFtMotion: FtMotion | null = null // for ; and , repeat private insertSession: InsertSession | null = null // tracks text on insert entry
pending is how operator + motion composition works. pressing d doesn’t immediately do anything – it sets pending = "d" and returns { consumed: true }. the next key press sees pending === "d" and knows to interpret the key as a motion. pressing w with pending === "d" triggers applyOperator("d", text, cursor, "w").
applyOperator calls getMotionRange to find the affected character range, then deletes it and stores the deleted text in register. for the c operator, it also switches to insert mode after deleting. for y, it just stores the range in register without modifying text.
text objects (iw, a", i{, etc.) go through applyTextObjectOperator instead, which calls findTextObjectRange to locate the object boundaries.
lastChange is a ChangeRecord:
interface ChangeRecord { type: "operator-motion" | "operator-textobj" | "insert-change" | "simple"; operator?: string; motion?: string; textobj?: string; insertedText?: string; // what was typed during the insert session simpleOp?: () => VimResult; // for x, dd, D, J, >>, << }
dot repeat replays whatever lastChange describes. for dw, it re-runs applyOperator("d", text, cursor, "w") against the current text and cursor. for cw{text}Esc, it re-runs the delete, switches to insert mode, inserts insertedText, then switches back to normal.
insertSession tracks the text content when we entered insert mode. on Esc, we diff the current text against insertSession.textBefore to find what was typed, and store that as insertedText in lastChange. this is how dot repeat knows what to re-insert.
lastFtMotion stores the last f/F/t/T operation:
interface FtMotion { type: "f" | "F" | "t" | "T"; char: string; }
; replays it in the same direction. , replays it in the opposite direction (f becomes F, t becomes T, and vice versa).
the key dispatch loop:
handleKey(event: VimKeyEvent, text: string, cursor: number): VimResult { if (this.mode === "insert") { if (event.key === "escape") { return this.enterNormal(text, cursor) } return { consumed: false } // let the textarea handle it } // normal mode const key = this.resolveKey(event) // normalize shift+key combos if (this.pending) { return this.handlePendingKey(key, text, cursor) } return this.handleNormalKey(key, text, cursor) }
in insert mode, the engine only intercepts Escape. everything else returns { consumed: false }, which tells the prompt component to let the key fall through to the textarea’s normal handling. this is the key insight: vim mode doesn’t replace the textarea’s input handling, it wraps it. regular typing in insert mode goes through the existing path unchanged.
layer 3: VimProvider
packages/opencode/src/cli/cmd/tui/context/vim.tsx is 35 lines:
import { createSignal } from "solid-js"; import { createSimpleContext } from "./common"; import { useTuiConfig } from "../../config/tui"; import { VimEngine, type VimMode, type VimKeyEvent } from "../lib/vim-engine"; export const { use: useVim, provider: VimProvider } = createSimpleContext({ name: "Vim", init: () => { const config = useTuiConfig(); const enabled = config.vim ?? false; const engine = new VimEngine(); const [mode, setMode] = createSignal<VimMode>( enabled ? "normal" : "insert", ); return { get enabled() { return enabled; }, get mode() { return mode(); }, handleKey(event: VimKeyEvent, text: string, cursor: number) { const result = engine.handleKey(event, text, cursor); if (result.modeChange) { setMode(result.modeChange); } return result; }, reset() { engine.reset(); setMode(enabled ? "normal" : "insert"); }, }; }, });
the provider’s job is to bridge the pure engine to SolidJS reactivity. mode is a signal so any component that reads vim.mode will re-render when the mode changes. the engine itself doesn’t know about signals – it returns modeChange in the result, and the provider calls setMode when it sees one.
VimProvider gets added to the provider tree in app.tsx, nested inside TuiConfigProvider (since it reads the config) but outside Prompt (since the prompt consumes it).
layer 4: prompt integration
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx has three touch points.
1. hook call – alongside the other context hooks at the top of the component:
2. key interception – at the top of the onKeyDown handler, right after the disabled check:
if (vim.enabled) { const result = vim.handleKey( { key: e.name, ctrl: !!e.ctrl, shift: !!e.shift, meta: !!e.meta }, input.plainText, input.cursorOffset, ); if (result.consumed) { e.preventDefault(); if (result.newText !== undefined) { input.setText(result.newText); syncExtmarksWithPromptParts(); setStore("prompt", "input", result.newText); } if (result.newCursor !== undefined) { input.cursorOffset = result.newCursor; } return; } }
when consumed is true, we apply the result and return early. the key never reaches the textarea’s default handler. when consumed is false (insert mode, regular typing), we fall through and the textarea handles it normally.
syncExtmarksWithPromptParts() is an existing function in the prompt component that re-applies syntax highlighting after text changes. we call it whenever we modify text through the vim engine, same as any other text modification path.
setStore("prompt", "input", result.newText) keeps the SolidJS store in sync with the textarea content. the store is the source of truth for the prompt value that gets sent to the agent.
3. mode indicator – in the tray section of the prompt JSX:
<Show when={vim.enabled}> <text fg={theme.textMuted}> {vim.mode === "normal" ? "-- NORMAL --" : "-- INSERT --"} </text> </Show>
this only renders when vim is enabled. the vim.mode signal drives it reactively – when you press i to enter insert mode, the indicator updates immediately.
cross-referencing against neovim’s source
the spec from claude code’s docs tells you what commands exist. it doesn’t tell you the exact semantics. for that, we went to neovim’s C source.
the key files:
src/nvim/textobject.c – word motions (fwd_word, bck_word, end_word) and the cls() character classification function src/nvim/normal.c – normal mode dispatch table, including the cw special case src/nvim/charset.c / src/nvim/mbyte.c – character classification (utf_class)
character classification
word motions in vim depend on character classification. from textobject.c around line 271:
static int cls(void) { int c = gchar_cursor(); if (c == ' ' || c == '\t' || c == NUL) return 0; c = utf_class(c); if (c != 0 && cls_bigword) return 1; // W/B/E: all non-blank = class 1 return c; }
three classes: 0 = whitespace, 1 = punctuation, 2+ = keyword characters. cls_bigword is set when processing W/B/E motions (WORD, not word), which treat all non-whitespace as the same class.
the default iskeyword setting is @,48-57,_,192-255, which expands to [a-zA-Z0-9_] plus the Latin-1 supplement range (characters 192-255). our isWordChar function uses /[A-Za-z0-9_]/, which matches the ASCII portion. for a prompt input that’s mostly code and English text, this is correct.
the w motion algorithm from fwd_word in textobject.c:
- get the class of the character at cursor
- skip all characters of the same class
- skip whitespace
- you’re now at the start of the next word
in C, the inner loop looks roughly like:
// simplified from fwd_word() int cls_start = cls(); while (!end_of_buffer()) { inc_cursor(); if (cls() != cls_start) break; } // now skip whitespace while (!end_of_buffer() && cls() == 0) { inc_cursor(); }
our TypeScript equivalent:
function findNextWordStart(text: string, cursor: number): number { if (cursor >= text.length - 1) return cursor; const startClass = charClass(text[cursor]); let i = cursor; // skip same class while (i < text.length - 1 && charClass(text[i + 1]) === startClass) { i++; } i++; // skip whitespace while (i < text.length && (text[i] === " " || text[i] === "\t")) { i++; } return i; } function charClass(ch: string): number { if (ch === " " || ch === "\t" || ch === "") return 0; if (isWordChar(ch)) return 2; return 1; // punctuation }
the b motion is the reverse: skip whitespace backward, then skip same-class characters backward. in TypeScript:
function findPrevWordStart(text: string, cursor: number): number { if (cursor <= 0) return 0; let i = cursor - 1; // skip whitespace while (i > 0 && (text[i] === " " || text[i] === "\t")) { i--; } // skip same class backward const endClass = charClass(text[i]); while (i > 0 && charClass(text[i - 1]) === endClass) { i--; } return i; }
the symmetry between w and b is clean. w skips forward past same-class then whitespace. b skips backward past whitespace then same-class. the only asymmetry is that w ends at the start of the next word while b ends at the start of the previous word – both are word-start positions, just in different directions.
three bugs found
bug 1: cw should behave like ce
from normal.c around line 5951:
/* * "cw" is a special case - it works like "ce" if the cursor is * on a non-blank. This is not Vi compatible, but it's what Vim * has always done. */ if (cap->cmdchar == 'c' && cap->nchar == 'w' && !u_save_cursor() && !lineempty(curwin->w_cursor.lnum)) { if (!vim_iswhite(gchar_cursor())) { cap->nchar = 'e'; } }
the comment says it all. cw maps to ce when the cursor is on a non-blank character. this is not Vi compatible – it’s a Vim-specific behavior that’s been there long enough that everyone expects it.
our initial implementation treated cw as a normal c + w composition, which deleted from cursor to the start of the next word (including the whitespace between words). the fix: in applyOperator, when the operator is c and the motion is w, check if the cursor is on whitespace. if not, redirect the motion to e.
// in applyOperator if (op === "c" && motion === "w") { const ch = text[cursor] ?? ""; if (ch !== " " && ch !== "\t" && ch !== "") { motion = "e"; } }
bug 2: e motion starting position
neovim’s end_word function always calls inc_cursor() first – it advances at least one character before looking for the end of a word. this means pressing e when you’re already at the end of a word moves to the end of the next word.
our initial findNextWordEnd started at cursor:
// wrong let i = cursor; while (i < text.length - 1 && isWordChar(text[i + 1])) { i++; }
if the cursor is at the last character of a word, this loop doesn’t advance at all and returns cursor. pressing e does nothing.
the fix is let i = cursor + 1:
// correct let i = cursor + 1; if (i >= text.length) return cursor; // skip whitespace while (i < text.length && (text[i] === " " || text[i] === "\t")) { i++; } // find end of word while (i < text.length - 1 && isWordChar(text[i]) && isWordChar(text[i + 1])) { i++; } return i;
bug 3: iw on whitespace
vim’s documentation for iw (inner word): “Select [count] words (see | word | ). White space between words is counted too, see | aw | if you don’t want this.” |
the actual behavior when the cursor is on whitespace: iw selects the whitespace run itself, not the adjacent word. aw selects the whitespace plus the adjacent word.
our initial implementation jumped to the adjacent word when the cursor was on whitespace, which is wrong. the fix:
function findWordRange( text: string, cursor: number, inner: boolean, ): [number, number] { const onWhitespace = text[cursor] === " " || text[cursor] === "\t"; if (onWhitespace) { // find the whitespace run let start = cursor; let end = cursor; while (start > 0 && (text[start - 1] === " " || text[start - 1] === "\t")) start--; while ( end < text.length - 1 && (text[end + 1] === " " || text[end + 1] === "\t") ) end++; if (!inner) { // aw: include the adjacent word (prefer the word after the whitespace) if (end + 1 < text.length && isWordChar(text[end + 1])) { while (end + 1 < text.length && isWordChar(text[end + 1])) end++; } else if (start > 0 && isWordChar(text[start - 1])) { while (start > 0 && isWordChar(text[start - 1])) start--; } } return [start, end]; } // cursor is on a word character let start = cursor; let end = cursor; while (start > 0 && isWordChar(text[start - 1])) start--; while (end < text.length - 1 && isWordChar(text[end + 1])) end++; if (!inner) { // aw: include trailing whitespace (or leading if at end of line) if ( end + 1 < text.length && (text[end + 1] === " " || text[end + 1] === "\t") ) { while ( end + 1 < text.length && (text[end + 1] === " " || text[end + 1] === "\t") ) end++; } else if ( start > 0 && (text[start - 1] === " " || text[start - 1] === "\t") ) { while (start > 0 && (text[start - 1] === " " || text[start - 1] === "\t")) start--; } } return [start, end]; }
the escape key name bug
this one wasn’t from neovim source – it came up during live testing. @opentui/core sends the escape key as "escape" (lowercase). we were checking for "Escape" (capital E). the engine never saw escape key events, so you couldn’t exit insert mode.
the fix is a single character change in the key dispatch:
if (event.key === "escape") { // not "Escape" return this.enterNormal(text, cursor); }
worth noting because it’s the kind of thing that’s invisible in unit tests if you’re not careful about what key names your test framework uses.
full vim mode reference
mode switching
| key | action |
i | enter insert mode at cursor |
I | enter insert mode at first non-blank of line |
a | enter insert mode after cursor |
A | enter insert mode at end of line |
o | open new line below, enter insert mode |
O | open new line above, enter insert mode |
Esc | return to normal mode / clear pending command |
navigation (normal mode)
| key | action |
h | move left |
j | move down (next line) |
k | move up (previous line) |
l | move right |
w | next word start |
e | end of current/next word |
b | previous word start |
W | next WORD start (whitespace-delimited) |
E | end of current/next WORD |
B | previous WORD start |
0 | beginning of line |
$ | end of line |
^ | first non-blank character of line |
gg | beginning of buffer |
G | end of buffer |
f{char} | jump to next occurrence of {char} on current line |
F{char} | jump to previous occurrence of {char} on current line |
t{char} | jump to just before next {char} on current line |
T{char} | jump to just after previous {char} on current line |
; | repeat last f/F/t/T in same direction |
, | repeat last f/F/t/T in opposite direction |
editing (normal mode)
| key | action |
x | delete character at cursor |
dd | delete entire line |
D | delete from cursor to end of line |
dw | delete to next word start |
de | delete to end of word |
db | delete to previous word start |
d0 | delete to beginning of line |
d$ | delete to end of line |
d{motion} | delete with any supported motion |
cc | change entire line (delete + enter insert) |
C | change from cursor to end of line |
cw | change to end of word (behaves like ce) |
ce | change to end of word |
cb | change to previous word start |
c{motion} | change with any supported motion |
yy / Y | yank entire line into register |
yw | yank to next word start |
ye | yank to end of word |
yb | yank to previous word start |
y{motion} | yank with any supported motion |
p | paste register after cursor (linewise: below current line) |
P | paste register before cursor (linewise: above current line) |
>> | indent current line by one shiftwidth |
<< | dedent current line by one shiftwidth |
J | join current line with next (adds space, strips leading whitespace) |
. | repeat last change |
text objects (used with d/c/y in normal mode)
| text object | description |
iw | inner word – the word under cursor, or whitespace run if on whitespace |
aw | around word – word plus surrounding whitespace |
iW | inner WORD – whitespace-delimited token |
aW | around WORD – WORD plus surrounding whitespace |
i" | inner double quotes – content between "..." |
a" | around double quotes – includes the quote characters |
i' | inner single quotes – content between '...' |
a' | around single quotes – includes the quote characters |
i( | inner parentheses – content between (...) |
a( | around parentheses – includes the parentheses |
i[ | inner square brackets – content between [...] |
a[ | around square brackets – includes the brackets |
i{ | inner curly braces – content between {...} |
a{ | around curly braces – includes the braces |
not supported (matching claude code)
| feature | reason |
visual mode (v/V) | not in claude code’s feature set |
count prefixes (3j, 5dd) | not in claude code’s feature set |
undo/redo (u, Ctrl+R) | not in claude code’s feature set |
search (/, ?, n, N) | not in claude code’s feature set |
replace char (r) | not in claude code’s feature set |
marks (m, `, ') | not in claude code’s feature set |
registers ("a, "b, etc.) | not in claude code’s feature set |
ex commands (:) | not in claude code’s feature set |
macros (q, @) | not in claude code’s feature set |
how to try it
the full source is on github: sngeth/opencode (feat/vim-keybindings)
git clone https://github.com/sngeth/opencode.git cd opencode git checkout feat/vim-keybindings bun install
create ~/.config/opencode/tui.json to enable vim mode globally:
you can also use .opencode/tui.json in a specific project directory, but the global path is better when running the compiled binary since CWD changes per project.
running as a compiled binary (recommended)
bun run dev from packages/opencode/ works but locks your terminal to the repo directory. sessions and file paths resolve relative to process.cwd(), so you want to invoke it from the project you’re actually working on.
compile a native binary instead. bun bundles everything – dependencies, the SolidJS JSX transform, tree-sitter parsers, migrations – into a single executable:
# from packages/opencode bun run build --single --skip-install
this produces a ~116MB binary at dist/opencode-<platform>-<arch>/bin/opencode. symlink it somewhere on your $PATH under a name that won’t shadow the official opencode binary:
ln -sf $(pwd)/dist/opencode-darwin-arm64/bin/opencode ~/.local/bin/opencode-vim
now opencode-vim works from any directory. sessions are scoped correctly and the official opencode stays untouched. rebuild after source changes with --single --skip-install (~10 seconds).
the mode indicator appears in the prompt tray. it shows -- NORMAL -- when you’re in normal mode and -- INSERT -- when you’re in insert mode. opencode starts in normal mode when vim is enabled, same as neovim’s default.
if you want to start in insert mode automatically, press i after the prompt loads. there’s no config option for the initial mode – that’s a deliberate choice to match how vim itself works.
files changed
| file | change |
packages/opencode/src/config/tui-schema.ts | added vim: z.boolean().optional() field |
packages/opencode/src/cli/cmd/tui/lib/vim-engine.ts | NEW – 957-line pure TypeScript state machine |
packages/opencode/src/cli/cmd/tui/context/vim.tsx | NEW – 35-line SolidJS context wrapping the engine |
packages/opencode/src/cli/cmd/tui/app.tsx | added VimProvider to the provider tree |
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | key interception in onKeyDown + mode indicator in tray |
five files. the engine is the bulk of it. the integration is thin by design – the engine does the work, the prompt just routes keys through it and applies the results.
the pure engine approach paid off during debugging. when the cw behavior was wrong, we could write a test that called engine.handleKey directly with a string and cursor position, without spinning up a terminal or a SolidJS component tree. the neovim source comparison was also easier because we were comparing TypeScript logic to C logic, not trying to trace through UI event handling at the same time.
the escape key name bug is a good reminder that integration tests matter even when unit tests pass. the engine was correct. the key name mismatch was in the boundary between @opentui/core’s event format and our engine’s expected format. that boundary only exists in the running application.
more gotchas (found after shipping)
the initial post covered three bugs found during development. three more showed up after the implementation was running in daily use.
x silently fails on trailing spaces
the symptom: pressing x at the end of a line with a trailing space does nothing. the character is there, the cursor is on it, but x returns without deleting.
the root cause is a cursor model mismatch. @opentui/core’s TextareaRenderable.cursorOffset is a gap-position – it sits between characters, like a text insertion point. vim’s normal mode cursor is a cell-position – it sits on a character. for the string "xxx " (four characters), cursorOffset=4 means “after the space” in the gap model. in vim’s model, that position doesn’t exist: the valid range is 0-3.
the x handler had an early return for this case:
if (safeCursor >= text.length) return { consumed: true };
when cursorOffset=4 and text.length=4, this fires and returns without deleting anything. the user sees nothing happen.
the same mismatch caused a second problem: pressing Escape to exit insert mode didn’t move the cursor left by one position. real neovim does this – if you’re at the end of a word in insert mode and press Escape, the cursor steps back one character so it’s sitting on the last character rather than past it. without that adjustment, the cursor stays at offset 4 after switching to normal mode, which puts it in the gap-position zone where operations silently fail.
two fixes in vim-engine.ts:
// before -- safeCursor could equal text.length in normal mode const safeCursor = this.clamp(cursor, 0, text.length); // after -- clamp to text.length - 1 so cursor is always on a character const safeCursor = text.length > 0 ? this.clamp(cursor, 0, text.length - 1) : 0;
// before -- escape exits insert mode but leaves cursor at end-of-text gap position return { consumed: true, modeChange: "normal" }; // after -- step cursor back one, matching neovim's behavior return { consumed: true, newCursor: text.length > 0 ? Math.max(0, cursor - 1) : 0, modeChange: "normal", };
the clamping fix is the more important one. any operation that checked safeCursor >= text.length or safeCursor >= end would silently fail when the cursor was at the end of text. x was the most visible case, but the same condition could affect other operators.
every shifted command is silently broken
pressing Shift+j to join lines moved the cursor down instead. pressing Shift+o to open a line above did nothing visible. every uppercase command – J, O, A, I, G, D, C, Y, P, W, E, B, F, T – was broken.
@opentui/core sends key events with the base character in e.name (always lowercase) and shift state as a separate boolean e.shift. so Shift+j arrives as { name: "j", shift: true }, not { name: "J" }. the vim engine was doing const key = event.key and checking against uppercase literals like if (key === "J"). the key was always lowercase. the uppercase branch never matched.
the lowercase branch matched instead: if (key === "j") → move down. Shift+j moved the cursor down when it should have joined lines. Shift+o hit if (key === "o") → open line below instead of above.
the fix is one line at the top of handleKey:
// before const key = event.key; // after const key = event.shift && event.key.length === 1 ? event.key.toUpperCase() : event.key;
when shift is held and the key is a single character, uppercase it before dispatch. this is the kind of bug that’s invisible in unit tests where you construct key events with the character you expect ("J") rather than the character the framework actually sends ("j" + shift: true).
escape key routing during generation
opencode has a session interrupt command – when a task is running, pressing Escape should abort the generation. after adding vim mode, that stopped working. pressing Escape during generation always triggered the interrupt instead of letting vim switch from insert to normal mode.
the root cause wasn’t in the textarea’s onKeyDown handler at all. opencode’s command system registers a global useKeyboard handler in dialog-command.tsx that fires before any component-level key handler:
// dialog-command.tsx -- global handler, fires first useKeyboard((evt) => { if (suspended()) return; if (dialog.stack.length > 0) return; for (const option of entries()) { if (!isEnabled(option)) continue; if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault(); option.onSelect?.(dialog); return; } } });
the session_interrupt command is registered with keybind: "session_interrupt" (mapped to "escape") and enabled: status().type !== "idle". during generation, every Escape keypress matches the interrupt command before the textarea ever sees it. vim never gets a chance to handle the key.
the fix is one line – make the interrupt command aware of vim’s mode:
// before enabled: status().type !== "idle", // after enabled: status().type !== "idle" && !(vim.enabled && vim.mode === "insert"),
when vim is in insert mode, the interrupt command is disabled. Escape passes through the global handler without matching, reaches the textarea’s onKeyDown, and vim processes it normally (insert to normal). on the next Escape, vim is in normal mode, the interrupt command is enabled again, and the abort fires.
the resulting behavior:
| mode | task running | Escape does |
| insert | yes | switches to normal mode |
| normal | yes | interrupts the generation |
| insert | no | switches to normal mode |
| normal | no | clears pending command |
this gives you the two-step sequence that feels natural: Escape to get to normal mode, Escape again to interrupt. it matches the mental model of “escape gets me out of whatever I’m in” – first out of insert mode, then out of the generation.
10 Mar 2026 in february 2026, alexey grigorev let his claude code agent run terraform destroy on what he thought were duplicate resources. the agent had silently unpacked an old terraform state file that pointed at production infrastructure instead. the entire course management platform for datatalks.club went down – database, vpc, ecs cluster, load balancers, bastion host, all gone. every automated snapshot was deleted with it.
“i over-relied on the ai agent to run terraform commands. i treated plan, apply, and destroy as something that could be delegated. that removed the last safety layer.”
the agent told him it would run terraform destroy. he approved it. the permission system worked – it asked. but he didn’t realize the state file had changed underneath him, so “yes, destroy those duplicates” actually meant “yes, destroy production.”
this is the hard lesson: ask only protects you if you understand what you’re approving. he saw terraform destroy and it looked logical in context. the agent had even explained its reasoning. but the premise was wrong because the state file was wrong, and no amount of prompting catches that.
“what happened was that i didn’t notice claude unpacking my terraform archive. it replaced my current state file with an older one that had all the info about the datatalks.club course management platform.”
his fix was to stop delegating entirely:
“agents no longer execute commands. every plan is reviewed manually. every destructive action is run by me.”
that works, but it’s also the nuclear option. opencode’s permission system offers a middle ground: let agents run safe commands freely, prompt you for risky ones, and hard-block the ones where no amount of context makes them safe to delegate.
the same class of problem applies to git worktrees. an agent running git restore, git reset --hard, or git clean can silently wipe uncommitted changes with no recovery path. here’s a practical config that draws the line.
the configuration
add this to your ~/.config/opencode/opencode.json:
{ "permission": { "bash": { "*": "ask", "git status*": "allow", "git diff*": "allow", "git log*": "allow", "git add*": "allow", "git commit*": "ask", "git stash*": "ask", "git checkout*": "ask", "git restore*": "ask", "git reset*": "ask", "git clean*": "deny", "git worktree*": "ask" } } }
how it works
opencode evaluates permission rules using last-match-wins ordering. the "*": "ask" catch-all at the top means any bash command not explicitly listed requires your approval. more specific rules below it override the default for their matched patterns.
three permission levels:
allow – runs silently, no prompt ask – prompts you with once/always/reject before executing deny – blocked outright, the agent cannot run it at all
test results
all tests run against a stub git repo with uncommitted modifications and untracked files present.
“allow” commands – ran without prompting
these are safe, read-only (or low-risk staging) operations. the agent runs them freely.
test 1: git status
rule: "git status*": "allow" | result: executed immediately, no prompt. |
$ git status On branch main Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) modified: main.py Untracked files: (use "git add <file>..." to include in what will be committed) untracked-work.txt no changes added to commit (use "git add" and/or "git commit -a")
test 2: git diff
rule: "git diff*": "allow" | result: executed immediately, no prompt. |
$ git diff diff --git a/main.py b/main.py index 110c807..11a6bc3 100644 --- a/main.py +++ b/main.py @@ -4,3 +4,7 @@ def greet(name): if __name__ == "__main__": print(greet("world")) + + +def farewell(name): + return f"Goodbye, {name}!"
test 3: git log
rule: "git log*": "allow" | result: executed immediately, no prompt. |
$ git log --oneline bf23d2b initial commit
test 4: git add
rule: "git add*": "allow" | result: executed immediately, no prompt. |
$ git add . (no output -- files staged successfully)
“ask” commands – prompted for approval
these commands can modify your working tree or history. opencode shows a prompt with once, always, or reject options before the command runs.
test 5: git commit
rule: "git commit*": "ask" | result: prompted, approved, then executed. |
$ git commit -m "test commit from agent" [main 7db8a3c] test commit from agent 2 files changed, 6 insertions(+) create mode 100644 untracked-work.txt
test 6: git stash
rule: "git stash*": "ask" | result: prompted, approved, then executed. |
$ git stash Saved working directory and index state WIP on main: 7db8a3c test commit from agent
test 7: git restore
rule: "git restore*": "ask" | result: prompted, approved, then executed. |
$ git restore main.py (no output -- changes silently discarded)
this is one of the most dangerous “quiet” commands. without the ask rule, an agent could silently discard your uncommitted modifications with zero output to indicate anything happened.
test 8: git checkout
rule: "git checkout*": "ask" | result: prompted, approved, then executed. |
$ git checkout feature-branch Switched to branch 'feature-branch'
test 9: git reset --hard
rule: "git reset*": "ask" | result: prompted, approved, then executed. |
$ git reset --hard HEAD HEAD is now at c44ce28 test commit from agent
this catches the most destructive variant (--hard) which resets the index and working tree, throwing away all uncommitted changes.
test 10: git worktree
rule: "git worktree*": "ask" | result: prompted, approved, then executed. |
$ git worktree add ../permission-test-wt main Preparing worktree (checking out 'main') HEAD is now at 7db8a3c test commit from agent
“deny” command – blocked entirely
test 11: git clean -fd
rule: "git clean*": "deny" | result: blocked. the agent received an error and the command never executed. |
$ git clean -fd Error: The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules: [ {"permission":"*","pattern":"*","action":"allow"}, {"permission":"bash","pattern":"*","action":"ask"}, {"permission":"bash","pattern":"git status*","action":"allow"}, {"permission":"bash","pattern":"git diff*","action":"allow"}, {"permission":"bash","pattern":"git log*","action":"allow"}, {"permission":"bash","pattern":"git add*","action":"allow"}, {"permission":"bash","pattern":"git commit*","action":"ask"}, {"permission":"bash","pattern":"git stash*","action":"ask"}, {"permission":"bash","pattern":"git checkout*","action":"ask"}, {"permission":"bash","pattern":"git restore*","action":"ask"}, {"permission":"bash","pattern":"git reset*","action":"ask"}, {"permission":"bash","pattern":"git clean*","action":"deny"}, {"permission":"bash","pattern":"git worktree*","action":"ask"} ]
the untracked file (expendable.txt) survived:
$ ls expendable.txt expendable.txt
git clean is the only command set to deny because it permanently removes untracked files with no recovery path – not even git reflog can help you. the ask tier at least gives you a chance to think; deny takes the option off the table entirely.
why this matters for worktrees
when you use git worktree to run multiple agents in parallel on different branches, each worktree has its own working tree but shares the same .git directory. this creates a unique risk surface.
commands that destroy work inside a worktree
git restore <file> – the most common agent culprit. agents run this reflexively to “undo” a bad edit. it silently discards your modifications with zero output. you’d never know it happened. git reset --hard – agents reach for this when things get messy and they want a “clean slate.” resets the index and working tree, all uncommitted changes gone. git checkout <branch> – if uncommitted changes conflict with the target branch, git overwrites your working tree files. agents switch branches casually. git clean -fd – deletes all untracked files permanently. new files that were never staged are gone with no recovery path, not even git reflog.
worktree management commands that destroy work
the worktree management commands themselves are also dangerous:
git worktree remove <path> – deletes the entire worktree directory. git refuses if there are uncommitted modifications, but agents will retry with --force when they hit the error, which bypasses the safety check and nukes everything. git worktree remove --force <path> – skips the dirty-tree check entirely. if an agent decides to “clean up” a worktree you’re still working in, all uncommitted files in that worktree directory are gone. git branch -D <branch> – while not a worktree command, an agent in one worktree can delete a branch that’s checked out in another worktree, leaving it in a broken state.
shared state risks across worktrees
all worktrees share the same .git directory. an agent operating in one worktree can affect others:
git gc --prune=now can clean up loose objects that other worktrees still reference - ref updates (tags, branches) in one worktree are immediately visible to all others
git stash in one worktree is accessible from all worktrees – an agent could accidentally pop another worktree’s stash
the "*": "ask" catch-all is critical. it means any command not explicitly listed – rm, curl, python, whatever – still requires your approval rather than running blind.
the datatalks.club incident would have been prevented with:
{ "permission": { "bash": { "terraform plan*": "ask", "terraform apply*": "deny", "terraform destroy*": "deny", "terraform import*": "ask" } } }
deny on terraform apply and terraform destroy forces you to run them yourself in a separate terminal where you see the full plan output and can verify which state file terraform is using. the agent can still generate plans and write config – it just can’t pull the trigger.
this wouldn’t have helped if he’d rubber-stamped an ask prompt the same way he approved the agent’s reasoning in chat. but deny removes the option entirely – the agent physically cannot execute the command, no matter how logical its explanation sounds.
additional recommendations
if you’re using multiple worktrees in different directories, add external_directory rules to control cross-worktree access:
{ "permission": { "external_directory": { "~/Code/my-project-wt-*": "ask" } } }
this ensures the agent in one worktree has to get your approval before touching files in another worktree that’s outside the directory where you launched opencode.
important notes
- restart required: permission changes in
opencode.json take effect on the next opencode launch, not mid-session. - last-match-wins: rules are evaluated in order and the last matching rule wins. put the
"*" catch-all first, specific rules after. - wildcard behavior:
* matches zero or more of any character (including spaces). "git status*" matches both git status and git status --porcelain. - per-agent overrides: you can set different permissions per agent in the
"agent" config section. for example, a review-only agent could have "edit": "deny". - “always” is session-only: when opencode prompts you and you choose “always”, that approval only lasts for the current session. it lives in memory, not on disk – it does not update
opencode.json. once you restart opencode, you’ll be prompted again. if you want a permanent allow rule, add it to opencode.json yourself.
24 Oct 2025 i built a command-line security tool to analyze shell scripts before executing them (preventing those dangerous curl | bash situations). starting with zig 0.15 meant hitting every breaking change head-on. here’s what actually broke and how to fix it.
the project: safe-curl
the tool analyzes shell scripts for malicious patterns:
- recursive file deletion (
rm -rf /) - code obfuscation (base64 decoding, eval)
- privilege escalation (sudo)
- remote code execution
zig seemed perfect for a simple CLI tool with minimal dependencies. then i hit the 0.15 changes.
source: safe-curl on github
roadblock 1: arraylist requires allocator everywhere
the change: zig 0.15 replaced std.ArrayList with std.array_list.Managed as the default. the “managed” variant now requires passing an allocator to every method call.
official reasoning: zig 0.15 release notes explain: “Having an extra field is more complicated than not having an extra field.” the unmanaged variant is now the primary implementation, with the managed version as a wrapper.
what broke
my initial attempt looked like this:
const Finding = struct { severity: Severity, message: []const u8, line_num: usize, }; const AnalysisResult = struct { findings: std.ArrayList(Finding), fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = std.ArrayList(Finding).init(allocator), }; } fn addFinding(self: *AnalysisResult, finding: Finding) !void { try self.findings.append(finding); // Error: missing allocator } };
error message:
error: expected 2 arguments, found 1
the fix
you have two options in 0.15:
option 1: store the allocator and pass it to methods
const AnalysisResult = struct { findings: std.ArrayList(Finding), allocator: std.mem.Allocator, // Store allocator fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = std.ArrayList(Finding).init(allocator), .allocator = allocator, }; } fn addFinding(self: *AnalysisResult, finding: Finding) !void { try self.findings.append(self.allocator, finding); // Pass allocator } fn deinit(self: *AnalysisResult) void { self.findings.deinit(self.allocator); // Pass here too } };
option 2: use the unmanaged variant
const AnalysisResult = struct { findings: std.ArrayListUnmanaged(Finding), fn init() AnalysisResult { return .{ .findings = .{}, // Empty initialization }; } fn addFinding(self: *AnalysisResult, allocator: std.mem.Allocator, finding: Finding) !void { try self.findings.append(allocator, finding); } fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void { self.findings.deinit(allocator); } };
i went with option 1 for familiarity, but option 2 is more idiomatic in 0.15.
why this change?
the zig team explains that storing the allocator in the struct adds complexity. with the unmanaged variant as default, you get:
- simpler method signatures
- static initialization support (
.{}) - explicit allocator lifetime management
trade-off: you pass the allocator everywhere, but your data structures are cleaner.
roadblock 2: empty struct initialization .{}
the pattern: zig 0.15 introduced a shorthand for empty struct initialization.
what this enables
before, initializing an empty arraylist required:
var findings = std.ArrayList(Finding).init(allocator);
now you can use struct field inference:
const AnalysisResult = struct { findings: std.ArrayList(Finding), fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = .{}, // Compiler infers std.ArrayList(Finding).init(allocator) .allocator = allocator, }; } };
this syntax confused me initially because .{} looks like an empty struct literal, but it actually calls the appropriate init function based on the field type.
when it works: field type is clear from context when it breaks: compiler can’t infer the type
var list: std.ArrayList(Item) = .{}; // Works var list = .{}; // Error: cannot infer type
roadblock 3: process api breaking changes
the change: std.process.Child.run() return type changed significantly.
what broke
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); // This line broke if (result.term.Exited != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } return result.stdout; }
error: no field named 'Exited' in union 'std.process.Child.Term'
the fix
the term field changed from having an Exited field to being a tagged union:
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); // Check the union variant properly switch (result.term) { .Exited => |code| { if (code != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } }, else => { allocator.free(result.stdout); return error.ProcessFailed; }, } return result.stdout; }
this is more explicit about handling different termination types (signal, unknown, etc.).
roadblock 4: http client instability
the problem: zig’s std.http.Client is still evolving rapidly between versions.
what i tried
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { var client = std.http.Client{ .allocator = allocator }; defer client.deinit(); const uri = try std.Uri.parse(url); var server_header_buffer: [16384]u8 = undefined; var req = try client.open(.GET, uri, .{ .server_header_buffer = &server_header_buffer, }); defer req.deinit(); try req.send(); try req.wait(); // ... read response }
errors:
- API mismatches between documentation and actual implementation
- buffer size requirements unclear
- response reading patterns changed between minor versions
the workaround
the zig 0.15 release notes acknowledge: “HTTP client/server completely reworked to depend only on I/O streams, not networking directly.”
this instability meant falling back to shelling out:
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { // Use curl as a fallback since the Zig HTTP client API is too unstable const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); switch (result.term) { .Exited => |code| { if (code != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } }, else => { allocator.free(result.stdout); return error.ProcessFailed; }, } return result.stdout; }
not ideal for a “zero dependency” tool, but pragmatic given the api churn.
roadblock 5: reader/writer overhaul (“writergate”)
the change: zig 0.15 completely redesigned std.io.Reader and std.io.Writer interfaces.
from the release notes: “A complete overhaul of the standard library Reader and Writer interfaces… designed to usher in a new era of performance and drastically reduce unnecessary copies.”
what changed
before (0.14):
const stdout = std.io.getStdOut().writer(); try stdout.print("Hello {s}\n", .{"world"});
after (0.15):
const stdout = std.fs.File.stdout(); try stdout.writeAll("Hello world\n"); // For formatted output, you need a buffer var stdout_buffer: [4096]u8 = undefined; var stdout_writer = stdout.writer(&stdout_buffer); try stdout_writer.print("Hello {s}\n", .{"world"});
why this matters
the old api wrapped streams in multiple layers of abstraction. the new api:
- builds buffering directly into reader/writer
- supports zero-copy operations (file-to-file transfers)
- provides precise error sets
- enables vector i/o and advanced operations
but it requires more explicit buffer management.
my approach
i created a helper function to hide the complexity:
fn printf(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void { const stdout = std.fs.File.stdout(); const msg = try std.fmt.allocPrint(allocator, fmt, args); defer allocator.free(msg); try stdout.writeAll(msg); }
this allocates for the formatted string, but keeps the call sites clean:
try printf(allocator, "{s}[{s}]{s} {s}\n", .{ color_code, severity_name, Color.NC, finding.message });
roadblock 6: undefined behavior rules tightened
the change: zig 0.15 standardizes when undefined is allowed.
from the release notes: “Only operators which can never trigger Illegal Behavior permit undefined as an operand.”
what this means
// This now errors at compile time const x: i32 = undefined; const y = x + 1; // Error: undefined used in arithmetic // Safe uses of undefined var buffer: [256]u8 = undefined; // OK: just reserves space const ptr: *u8 = undefined; // OK: pointers can be undefined
this catches bugs earlier but requires more explicit initialization.
the practical impact
in my code, i couldn’t do:
var line_num: usize = undefined; while (condition) : (line_num += 1) { // Error // ... }
had to initialize explicitly:
var line_num: usize = 1; while (condition) : (line_num += 1) { // ... }
the verdict: worth it?
what’s better in 0.15:
- 5Ă— faster debug compilation with x86 backend
- clearer allocator lifetime management
- more explicit, less magic
- better performance fundamentals
what hurts:
- breaking changes everywhere
- documentation lags behind implementation
- http client still unstable
- community examples are all outdated
resources