Keep controls in editor text and render them as safe, visible notation. This lets the cursor, selection, wrapping, and offsets handle controls consistently without sending them to the terminal. Strip terminal escape sequences from pasted text so pasted controls cannot change terminal state. Alternative to #1632 and aligned with how vim does it. We accidentally in pre 0.6 and explicit in 0.6.x rejects edit text that contains a C0 control other than tab, carriage return, and line feed, DEL, or a C1 control: `setText()`, `insertText()`, `replaceText()`, Textarea `initialValue`, and `Input.value` throw `NativeError` with `InvalidArgument`. The reason is that the editor places its cursor by display cell, and a control took no cells, so the cursor could neither move past it nor delete it. In 0.5.14, which accepted controls, the cursor in `"a\fb"` could not reach column 0, Backspace stopped at `"a\f"`, and rendering `"A\x07B\x1bC\fD"` wrote raw BEL and ESC bytes to the terminal while `C` and `D` vanished. This is an alternative to #1632, which drops these characters. Instead, editors keep them and show them the way Vim and Neovim do. Measured with Neovim 0.12.5 and Vim 9.2, both show `a^Lb^@c^[d<85>e^?f`: a C0 control or DEL as caret notation in 2 cells, and a C1 control as hex in 4 cells. The cursor stops on each one, `x` deletes one, and writing the file back gives identical bytes. Editors now do the same. A per-buffer `ControlDisplay` setting is passed with the tab width to the native width functions, so cursor movement, deletion, wrapping, selection, history, and drawing share one rule: - C0 controls other than tab, LF, and CR, and DEL, show as `^@`..`^_` and `^?` in 2 cells. C1 controls show as `<80>`..`<9f>` in 4 cells. Tab, LF, and CR keep their current behavior. - Cells still never hold a raw control: drawing writes the notation's printable glyphs. - Each control is its own grapheme, so it no longer joins the next character, and a soft wrap never splits its notation. - `getText()`, undo and redo, and selection copy return the original bytes. - Read-only text keeps hiding controls with zero width, so `TextRenderable`, the console, and other displayed text do not change. Only `EditBuffer` and its placeholder use notation. Text input changes with it: - Paste keeps controls, as Vim does, and still removes escape sequences. The stripper is now one shared implementation, so Bun and Node give the same result, and core no longer depends on `strip-ansi`. A lone ESC and a truncated sequence are removed, so a paste never inserts ESC. Key sequences with a control stay ignored, so Escape does not insert `^[`. - `Input` puts its cursor at the native end of the value instead of the string length. - Extmarks convert display offsets through native `offsetToPosition` instead of JavaScript string widths, and Right stops at the native end of the buffer before a virtual extmark. `deleteLine` adjusts extmarks by native line offsets. - `EditBuffer.measureText(text)` and `ot_edit_buffer_measure_text` return the display offset at the end of a text as the buffer would lay it out, without storing it. JavaScript width functions give a control and a tab no cells, so an app that computes editor offsets in JavaScript needs this: in `"\f@fi"` the `@` is at offset 2, not 0. Along the way, these bugs that already exist in are fixed: - A standalone zero-width cluster (zero-width space, word joiner, BOM) blocked Left and Backspace in the Unicode width methods. - After `"日\u{fffd}"`, Backspace deleted the wide character. In the no_zwj method, one emoji step had the wrong size. - U+FFFD inside a joining sequence, such as `"क्\u{FFFD}\u{301}ष"`, made layout and cursor movement disagree: Right got stuck, and Backspace deleted the wrong cells. - An extmark ending at a line end after a tab lost its last cell. Right could step into a virtual extmark after CJK text. `deleteLine` moved extmarks by UTF-16 indexes. `Input` placed its cursor in the middle after CJK text. Backspace no longer measures the whole chunk for each step. The native "backspace 500 chars" benchmark takes 0.21 ms instead of 25.3 ms on v0.6.0; other benchmarks match v0.6.0 within noise. Most of the diff passes the new setting to the width functions: of the added lines, most only add `.hidden`, `.notation`, or `control_display`, and others of them in existing tests. Net source is +86 lines and net tests +95. Not included: a distinct style for the notation (like Vim's SpecialKey), a literal-insert key, `^M` for a lone CR, controls as word boundaries, and clipping wide units cell by cell at the left edge of a horizontally scrolled view. Fixes #1642
Native API
OpenTUI uses Context-owned scenes and Sessions for production rendering, including text, editors, custom paint hooks, images, detached surfaces, and split output. Standalone resources use the same checked ownership model without a terminal.
Read Resources and ownership for resource bindings, text copy units, and scoped framebuffer access. Frames and output defines mutation admission, painted drafts, and transport completion. How Core uses native explains TypeScript staging and resource ownership. Host I/O and time defines Context file operations, Session deadlines, and native diagnostic clocks.
API surfaces
src/opentui.zigis the public Zig module. It exports the checkedContextAPI and raw Zig primitives, includingCliRenderer,NativeRenderable,OptimizedBuffer, text buffers, and pools. Callers manage the lifetimes of raw primitives. Importing a raw primitive does not add Context checks to it.include/opentui.hdefines the checkedot_*C ABI for Contexts, Sessions, scenes, drawing, text, editors, styles, leases, clipboard, and diagnostics. The ABI is experimental and at version 1. Core renders through it.../core/src/zig.tssupplies TypeScript wrappers over that checked ABI. Its checked signatures, callbacks, constants, and record layouts come fromnative-abi.generated.ts.
ABI generation and builds
Use C headers and libraries from the same revision. Set each versioned record's
exact struct_size and abi_version, and leave unused flags and reserved fields
zero. Follow the output and failure contract of each operation in the header.
From packages/core:
bun run generate:abi
bun run check:abi
bun run test:abi
scripts/native-abi.ts uses Zig Translate-C and
scripts/native-abi.zig reflection to derive scalar
widths, signatures, callback types, constants, record sizes, alignment, and field
offsets from the header. Pointer nullability, retention, address fields, and portable
buffer/ptr policy live in
scripts/native-abi-pointers.ts, because C
types cannot prove lifetimes. Review that metadata when ownership contracts change.
Do not edit generated bindings. check:abi detects stale output. Use
bun run check:abi --all-targets to also compare the layouts of all supported targets.
An unsupported record shape or calling convention fails the script. It does not
produce partial metadata. C compiler assertions also check complete function and
callback prototypes, record layouts, field types, and constant values. Translate-C
alone is not enough, because it can drop callback calling-convention attributes and
ignore #pragma pack.
From packages/native, bun run build installs the header and libraries under
lib/<target>/ for the host target. Linux and macOS produce libopentui.a beside
the shared library. Windows produces opentui-static.lib, opentui.lib for DLL
imports, and opentui.dll. Static linkage also needs the platform and C++ runtime
libraries. zig build -Dall builds all supported targets.
zig build -Dlibrary-target=<target> builds one target.
zig build test-abi --summary all
This command runs the C fixture against the static and shared libraries on the host.
On a glibc Linux host, the fixture targets glibc 2.17. check:abi --all-targets
checks layouts for all eight supported targets. It does not test macOS or Windows
runtime linkage or terminal behavior.
The external examples/hello package imports the public Zig module
without JavaScript.