Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TUI Restoration & Safe Teardown

Who this is for — anyone shipping a terminal program that renders inline and has to give the terminal back intact.

Read this if — you are writing, reviewing, or debugging a TUI.
Skip this if — you write no terminal UIs; the specifics here do not generalize.

Takeaways

  1. Teardown is a contract with a strict order, and every exit path — including panics and signals — must honour it.
  2. Most terminal bugs are timing bugs: drain in-flight input before you restore, or the user’s shell eats it.
  3. Terminals disagree. Verify on VTE, on Foot, and under tmux, because no harness sees a cursor warp.

If you read one chapter of this book for craft rather than method, read this one.

Building inline TUIs (TUIs that render inline inside the normal terminal scrollback rather than switching to the alternate screen buffer) requires surgical precision. A sloppy exit sequence leaves empty rows, corrupts terminal settings, or leaks escape sequences into the user’s shell prompt. Users forgive a missing feature; they do not forgive a tool that wrecks their terminal on the way out.

The following conventions are derived from the development of emojig’s TUI engine — every rule below was paid for with a real bug on a real terminal.

$ ls src/ build/ Makefile $ emojig search: zap▌ ⚡ 🔍 ✨ 🚀 ✅ rows 4–7: TUI footprint exit $ ls src/ build/ Makefile $ emojig ⚡ copied to clipboard $ ▍ footprint erased, cursor parked
The teardown goal: after exit, the scrollback looks like the TUI was never there.

1. The Inline Teardown Contract

On every exit path (normal selection, cancellations, signals like SIGINT or SIGTERM, and panic panics), the TUI must perform the following actions in strict chronological order:

  1. Disable Mouse Tracking: Turn off mouse tracking sequences (e.g., \x1b[?1003l\x1b[?1006l).
  2. Drain In-Flight Input (CPR Handshake): Before restoring cooked mode, write a Cursor Position Report query (\x1b[6n) and read the input stream until receiving the response (terminating with R). Because terminals process I/O in FIFO order, receiving this response guarantees that all mouse movement bytes queued before mouse tracking was disabled have been drained.
  3. Restore Termios: Restore the raw terminal attributes back to the saved cooked mode state.
  4. Clean the Footprint: Erase only the rows the TUI occupied. Loop through the rows and emit \x1b[2K (erase line). Do never use \x1b[J or \x1b[2J (erase display) as this destroys the user’s terminal scrollback buffer.
  5. Park the Cursor: Absolute-position the cursor (\x1b[row;1H) at the start row recorded at launch. Do not emit any character or escape code after this that could shift the cursor, preventing “stray blank lines” before the next shell prompt.

This contract was written in the first week of emojig — the very first commit established raw termios, signal safety, and the double-buffered surface before a single emoji was ever drawn. See Week 1 of the Emojig Chronology for the original code and the fourteen PTY-harness iterations it took to trust it.

🤖 Agent takes the wheel — wiring the same teardown sequence into every exit path: normal return, cancel, SIGINT, SIGTERM, panic handler. It is exactly the kind of exhaustive, easily-forgotten coverage humans skip.

🛡️ The check that makes it safe — a PTY harness that launches the binary, sends each exit trigger, and asserts on the captured byte stream: mouse tracking off, CPR consumed, no \x1b[J, cursor parked at the start row.


2. Cross-Terminal Alt-Screen Pitfall (VTE vs. Foot/Tmux)

The private mode sequence ?1049h/l switches to/from the alternate screen and saves/restores the cursor.

  • The Pitfall: If your TUI runs inline (on the normal screen) and you emit ?1049l during teardown just to be safe, GNOME Terminal, Tilix, Ptyxis, and other VTE-based terminals will still execute the cursor-restore half of the command, warping the cursor to a stale saved position and corrupting the next shell prompt line. Foot and tmux will silently ignore this.
  • Convention: Only emit ?1049l if you explicitly emitted ?1049h to enter the alternate screen. Maintain separate RESTORE (inline) and RESTORE_ALT (alt-screen) sequences.

3. DEC Private Modes and ? Wrap Safety

Every terminal mode used for TUI rendering (?25 cursor, ?1003/?1006 mouse, ?7 auto-wrap) is a DEC private mode.

  • The ? is load-bearing: Omitting the ? (e.g. sending \x1b[7l instead of \x1b[?7l to disable auto-wrap) results in an unrecognized command or fires a completely different ANSI mode.
  • Wrap Disabling: Always disable auto-wrap (\x1b[?7l) during inline renders. If an emoji or graphic wraps at the bottom row of the screen, the terminal will scroll, invalidating the recorded start coordinates and breaking the teardown row calculations.

4. Mitigating SGR Mouse Parser Leaks

When mouse tracking is active, the terminal emits SGR sequences (\x1b[<Cb;Cx;CyM) at high frequencies (up to 125 Hz). If the user scrolls or drags a scrollbar quickly:

  1. The Trap: The read buffer might split an event (e.g., buffer ends at \x1b[<32;32;). The trailing bytes (12M) arrive in the next read without a leading escape byte, resulting in the TUI treating them as text input and typing them into the search box.
  2. The Fix:
    • Use a larger read buffer (at least 512 bytes) to hold multiple full events.
    • Use a multi-event parser loop that processes all consecutive events in a single read.
    • Keep a carry buffer for incomplete events. If a read ends inside an SGR sequence, copy the fragment to the carry buffer and prepend it to the next read.
    • In panic handlers, perform a non-blocking drain of the input stream to clear the terminal’s queue.

See Emojig Week 4 for the dead end that paid for this rule — the week 12M started appearing in the search box.


5. Exact-Width Pending-Wrap Erase Bug (\x1b[K)

  • Symptom: The very last character of a row (such as a border cap ▐) is drawn but is invisible on exact-width windows.
  • Root Cause: Writing a character to the final column N leaves the cursor in a pending-wrap state. Running a standard \x1b[K (erase to end of line) from this state clears the character that was just drawn.
  • Fix: For rows that span the entire terminal width, avoid trailing erase commands (\x1b[K). Emit only character reset codes (\x1b[0m).

See Emojig Week 5 for how this one was found — a border cap that was drawn, present in the output bytes, and still invisible.


6. Visual Verification Protocol

Automated PTY harnesses cannot detect visual coordinate drift or VTE-specific cursor warps. Always visually verify teardowns on:

  1. A VTE Terminal (e.g. GNOME Terminal) to verify alternate screen boundaries.
  2. Foot to inspect standard-error logs for unimplemented sequences or bad modes.
  3. Tmux to verify compatibility with terminal multiplexers.

🧑‍✈️ Human holds the wheel — the eyes. A cursor warped by two rows, a flicker on resize, a prompt that lands one line too low: no assertion in any harness sees these, and they are precisely the defects users notice first.

One structural defence against escape-sequence rot is to stop scattering the literals. In Week 5 of the Emojig Chronology, 100+ raw ANSI escapes were centralized into a term.zig utility module, moving 145 lines out of the rendering loops — the same week search latency fell from ~4.2 ms to ~0.36 ms per keystroke. Escape sequences you can only fix in one place are escape sequences an agent can fix safely.


7. CPR Handshake Race Condition & State-Machine Parser

When querying the cursor position during raw-mode startup or resizing using \x1b[6n, the response \x1b[ROW;COLR is sent by the terminal to standard input.

  • The Race Condition: If the user is typing rapidly while the TUI launches or resizes, the user’s keystrokes and the terminal’s Cursor Position Report (CPR) bytes will interleave in the OS input buffer (e.g., a\x1b[24;10Rb).
  • The Trap: Relying on simple string comparison or regex matching to parse the CPR will fail or eat user keystrokes.
  • The State-Machine Parser: Process stdin character-by-character using a sequential state machine. Parse escape sequences into a dedicated struct and route any non-escape character directly to the TUI’s command buffer or search query. This guarantees that no user input is lost and the CPR is parsed accurately.

8. Grid Layout Remainder Math Proof

When rendering a TUI grid (like an emoji picker or file browser) across a fixed terminal width W, columns are allocated a width C.

  • The Gutter/Overflow Problem: If W mod C ≠ 0, the remaining space R = W mod C causes an empty column gutter on the right or layout overflow on the margins.

  • Remainder Distribution Invariant: Distribute the R leftover cells across the columns as padding, widening the first R columns by one cell:

    width(i) = C + 1   for the first R columns
    width(i) = C       for the remaining columns
    
    width(1) + width(2) + … + width(N) = W
    

    The widths sum back to exactly W, so the grid bounds snap to the terminal width without empty gutters or right-margin border truncation.


9. Subprocess Lifecycle Management in Sandboxes

Running terminal tools or helpers inside sandboxes (like Snap confinement, Flatpak, or container mounts) restricts sub-process execution.

  • Mount Namespaces: Sandboxed applications (e.g., Snap-confined Chromium) run in isolated mount namespaces and cannot write to the system /tmp directory. Always output files (such as temporary PDFs or assets) to directories within the user’s home folder (e.g., ~/.cache or /home/uwe/projects/...).
  • Orphaned Subprocesses: If the parent TUI process crashes or receives a terminal signal, any spawned child process (like video pipelines, rasters, or encoders) will keep running, polluting the system resources.
  • PDEATHSIG Control: On Linux, immediately after spawning a subprocess, set the process death signal attribute using raw system calls:
    // In Go
    syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGKILL), 0)
    
    This instructs the OS kernel to automatically send SIGKILL to the child process the instant the parent thread dies, preventing orphaned processes.