Week 1: Foundations & Shell Widgets
Theme — Taking the terminal hostage safely: raw mode, teardown, and the
/dev/ttysplit.Skip this if — you already have a TUI that exits cleanly on every path, including panics.
Takeaways
- Copy this: write the restore sequence before the first feature, and call it from normal exit, every signal handler, and the panic handler — one function, four call sites.
- Avoid this: rendering to
stdout. The moment you want a shell widget,stdouthas to be a data channel; render to/dev/ttyfrom day one and you never pay for the split.- The check that caught it: launching from a real VTE terminal, not just
foot. The alt-screen cursor warp below is invisible on the terminal we developed in.
The Genesis of the TUI Core
Every terminal program begins the same way: with a single escape code that takes the terminal hostage. Get raw mode right and nobody notices; get it wrong and the user’s shell is wrecked. Week 1 was entirely about earning that trust — before a single emoji was ever drawn.
During Week 1 (May 29 – June 4, 2026), the emojig repository was initialized. The core objective was to build a zero-dependency, ultra-lightweight, and lightning-fast emoji fuzzy search tool in Zig.
Week 1 by the Numbers
- Total Commits: 171 — very nearly as many as the remaining five weeks combined (187). See Reading the Curve for why that is a property of exploration rather than of productivity.
- Codebase Expansion: From 0 lines of code to 1,250 LoC in Zig, 180 LoC in Go (pack script), and 420 LoC in shell integrations.
Daily Chronology
May 29, 2026: The Initial Prototype (cd0029a)
The project began by writing the raw terminal interface and the fuzzy subsequence search engine.
- Features Added: Fuzzy search matcher, embedded database loader, and raw termios attributes hook.
- Lines of Code (LoC) Added:
src/main.zig: 204 LoC.src/root.zig: 134 LoC.tools/pack_emojis.py: 82 LoC.
Code Archaeology: Initial Termios & Rendering Loop
The very first commit established the raw mode, signal safety, and the double-buffered rendering surface. Here is how standard terminal attributes were captured and modified in src/main.zig (Line 29 onwards):
// Switch to alternate screen, enable mouse tracking, hide cursor
const stdout_fd = std.posix.STDOUT_FILENO;
try writeAll(stdout_fd, "\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[?25l");
// Save and configure raw termios
const stdin_fd = std.posix.STDIN_FILENO;
const orig_termios = try std.posix.tcgetattr(stdin_fd);
var raw = orig_termios;
raw.iflag.IGNBRK = false;
raw.iflag.BRKINT = false;
raw.iflag.PARMRK = false;
raw.iflag.ISTRIP = false;
raw.iflag.INLCR = false;
raw.iflag.IGNCR = false;
raw.iflag.ICRNL = false;
raw.iflag.IXON = false;
raw.oflag.OPOST = false;
raw.lflag.ECHO = false;
raw.lflag.ECHONL = false;
raw.lflag.ICANON = false;
raw.lflag.ISIG = false;
raw.lflag.IEXTEN = false;
raw.cflag.CSIZE = .CS8;
raw.cflag.PARENB = false;
The orig_termios value captured here is the single most important variable in the program: it
is the only copy of the user’s shell settings, and every exit path has to put it back.
Terminal Safety Overrides (5420d21, eab5e91)
In raw mode, if the application crashes or receives a termination signal, the terminal is left in an uncooked state (cursor hidden, mouse tracking active, characters not echoed). To prevent leaving the developer’s console broken, POSIX signal handlers and a custom panic function were written to intercept failures and restore cooked properties:
pub fn panic(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
// Restore standard termios settings before crashing
const stdin_fd = std.posix.STDIN_FILENO;
const stdout_fd = std.posix.STDOUT_FILENO;
std.posix.tcsetattr(stdin_fd, .FLUSH, g_orig_termios) catch {};
writeAll(stdout_fd, "\x1b[?1000l\x1b[?1006l\x1b[?1049l\x1b[?25h") catch {};
// Resume standard stack dumping
std.builtin.default_panic(msg, error_return_trace, ret_addr);
}
Note the order of the restore string: mouse tracking off, then screen, then cursor. Also note
the \x1b[?1049l sitting in the middle of it. That sequence is correct here — this early build
entered the alternate screen — and it is the exact byte that would cost us a day once the picker
learned to render inline. The story is below.
May 30, 2026: The SGR Mouse and Layout Grid
With the raw TUI functioning, mouse tracking and theme state persistence were added.
- Features Added: SGR mouse coordinates query, hover selections, click confirmations, and theme storage mapping to
~/.config/emojig/config. - LoC Added:
src/main.zigexpanded to 410 LoC (+206).src/config.zigcreated: 64 LoC.
May 31, 2026: Splitting TTY & stdout (Shell Widgets)
The initial prototype copied the selected emoji to the clipboard using wl-copy. However, to build interactive shell integrations (e.g. hitting Ctrl+E at a shell prompt to search and insert the emoji directly at the cursor), the TUI could not print to stdout, as the shell needs to read the selected codepoint directly from the tool’s stdout pipe.
- The Split Solution (
c2aa663): We redirected all rendering writes explicitly to/dev/ttyrather thanstdout. This leftstdoutcompletely clean to emit only the selected emoji string. - LoC Added:
src/main.zigmodifications (+42).shell/emojig.zsh,shell/emojig.bashcreated: 110 LoC.
# How Zsh intercepts stdout of emojig
emojig_widget() {
local selected
selected=$(emojig --tui)
LBUFFER="${LBUFFER}${selected}"
zle reset-prompt
}
Figure 1: the payoff of the split. The picker paints to /dev/tty below the prompt; the chosen
emoji arrives on stdout and lands inside the half-typed echo command above it.
June 1, 2026: Resize Epic & Alt-Screen Isolation
As the picker gained adoption as an inline TUI, users running narrow terminals reported visual bugs: resizing the screen would wrap contents, and scrolling would duplicate viewports.
- The Fixes (
d60c66b,f5ced19):- CPR Queries: Wrote cursor positioning queries (
\x1b[6n) to determine the TUI’s exact launch row. - Relative Positioning: Changed all redraw offsets to use relative coordinates based on the launch row rather than absolute page rows, keeping the TUI anchored.
- Hiding Overflow: If the terminal width shrunk below 40 columns or 6 rows, the renderer collapsed layout details gracefully (hiding description and switcher rows) rather than warping characters.
- CPR Queries: Wrote cursor positioning queries (
- LoC Added/Modified:
src/main.zigexpanded to 730 LoC (+278).src/resize.zigcreated: 112 LoC.
Dead End: The Cursor That Jumped Home
Going inline meant the picker no longer entered the alternate screen — but the teardown string
written back in the first commit still ended with \x1b[?1049l. Leaving it in felt harmless.
Nothing is harmless in a teardown.
- What we saw: on exit, the shell prompt came back in the wrong place. Sometimes it overwrote the line above; sometimes it landed several rows up, on top of scrollback that had nothing to do with us. Nothing was corrupted — the terminal was cooked and usable — the cursor was simply somewhere else. And it only happened on some machines.
- What we assumed: a row-arithmetic bug in the brand-new relative-positioning code. We had just replaced absolute page rows with offsets from the CPR-detected launch row, so an off-by-N in the erase loop was the obvious suspect. We spent the search there.
- What it actually was:
?1049h/lis two operations welded together — switch the screen buffer, and save/restore the cursor. VTE-based terminals (GNOME Terminal, Tilix, Ptyxis) honour the cursor-restore half even when you never entered the alternate screen, warping the cursor to a stale saved position.footandtmuxignore a?1049lthat has no matching?1049h, which is why the bug was invisible in our own development terminal and reproduced instantly on someone else’s. The arithmetic had been right the whole time.
The fix was to stop maintaining one restore string and maintain two: a RESTORE for inline mode
that never mentions ?1049, and a RESTORE_ALT for the alt-screen path that does. The rule it
became is TUI Restoration §2: only emit ?1049l if you
explicitly emitted ?1049h. The habit it became is more general — verify teardown on a VTE
terminal, because the terminal you develop in is the one that forgives you.
June 2 – June 4, 2026: Cross-Terminal Detours
We verified the static binaries across terminal environments (kitty, alacritty, ghostty, tmux, and Linux virtual consoles via fbterm). We automated multi-platform builds using GoReleaser and prepared the v0.1.5 compiler tags.
- LoC Added:
.goreleaser.yamlcreated: 120 LoC.scripts/test_tui.gocreated: 135 LoC.
Week 1 verdict: a working picker with safe teardown, shell widgets, and releases — but every string, color, and key binding was hardcoded in Zig. That debt sets the agenda for Week 2.