Skip to content

Async Gaps and Direction

Async Applications documents the rules that async Ratatui applications follow today: pick one terminal owner, treat drawing as blocking I/O, treat query replies as input, drain bursts before drawing, serialize handoffs. Those rules work, but they live in documentation and application code instead of in library types. Each one is a workaround for a boundary that the libraries do not yet provide.

This page inventories those gaps and describes the direction of work that would move each rule from convention into API. It is a statement of direction, not a schedule. Designs described here are in progress and subject to change. Where a status line refers to work-in-progress crate development, that work is currently private prototyping — read those lines as direction the authors are exploring, not as artifacts you can inspect or build against yet.

Rule you follow todayMissing library boundary
Pick one terminal ownerOwnership as a type that serializes terminal use
Treat query replies as inputA protocol router that resolves replies and forwards events
Treat drawing as blocking I/OA render/present split with frames as values
Drain bursts, then draw onceFrame scheduling in the library
Serialize handoffsA session lifecycle: release, reacquire, suspend, resume
Cancel or tag stale workApplication-level, with better primitives

When a boundary exists in the library, the corresponding rule stops being something an application can get wrong. The sections below take each gap in turn: what breaks today, what fixed looks like, and where the work stands.

Today. Terminal queries write a request and receive the reply through the same input stream that carries keys, paste, mouse, and resize events. Whichever reader consumes the bytes first wins. This is the largest single class of failures in Failure modes: cursor-position timeouts (ratatui/ratatui#2483), replies lost to EventStream (crossterm/crossterm#1039), color-query replies misclassified as pasted input (Codex color-query patch), and queries that fail under redirection (crossterm/crossterm#919). The underlying design issue has been open in Crossterm since crossterm/crossterm#763.

Fixed looks like. One reader task owns the terminal input. Queries are futures: the reader matches replies against pending queries and resolves them; bytes that match no pending query flow through as ordinary events, in order. Late, malformed, and unmatched replies have defined behavior instead of racing. Redirected stdio fails fast instead of timing out. Termina’s filtered event reader shows one shape of this design: filters with buffered rejected events, inside the shared reader.

Status. A private prototype is exploring this router design. Its tests cover reply matching, late and wrong-report replies, unmatched query-shaped input, and preserved unrelated input; redirected-terminal behavior is part of the design but not yet settled coverage.

Today. Terminal::draw is one fused operation: query the size, run the render closure, diff the buffers, write the changed cells, and flush (Terminal::try_draw source). The fusion is why the rules exist: the whole operation must happen under the terminal owner, it blocks whichever thread or task runs it, and with inline viewports it can also read terminal input mid-draw (compute_inline_size source). Async wrappers cannot fix this from the outside; they can only schedule around it.

Fixed looks like. Rendering and presenting are separate steps. Render turns application state into a Buffer — a pure, Send value that can be produced on any thread, with the size supplied as an input from resize events rather than queried mid-draw. Present diffs the newest buffer against the screen and writes, under the owner. Frames-as-values makes the hard patterns from the coordination patterns section nearly free: latest-value presentation is a watch channel of buffers, coalescing is “present the newest,” and render work stops competing with the executor. Inline viewports would route their cursor query through the same query router as everything else.

That paragraph glosses over real design questions — autoresize semantics, cursor positioning, and backend compatibility among them. This split needs a design discussion in ratatui-core, not a patch.

Status. Under design. A known escape hatch — a backend that captures draw bytes and flushes them asynchronously — has been prototyped and informs the design, but it inherits the fused semantics rather than fixing them.

Every application rebuilds frame scheduling

Section titled “Every application rebuilds frame scheduling”

Today. Nothing in the stack coalesces redraws, so every substantial application builds it: Codex has a frame scheduler, Yazi has render flags, Helix debounces request_redraw. Smaller applications copy the dirty-flag-and-budget pattern from Drain bursts before drawing — or do not, and redraw once per event until input lags.

Fixed looks like. A redraw handle in the library layer: background work requests a frame, requests coalesce under a frame budget, and the owner presents once. If the render/present split lands, most of this machinery dissolves — “present the newest buffer at the frame budget” is a small amount of code, not a subsystem.

Status. Blocked on the render/present design above, deliberately: building the scheduler first would bake the fused-draw shape in.

Handoffs and suspend/resume are hand-rolled

Section titled “Handoffs and suspend/resume are hand-rolled”

Today. Handing the terminal to a child process, or surviving Ctrl+Z / fg, takes a precise sequence — pause the reader, restore modes, run the child, re-enter modes, flush stale input, resume — and the library provides none of it as an operation. Codex rebuilt its event stream around a broker to make the reader stoppable (Codex EventStream refactor) and fixed suspend/resume byte injection by hand (Codex suspend fix); gitui suspends its input thread around external editors (gitui input thread).

Fixed looks like. Release and reacquire as first-class session operations: release stops the reader, restores modes, and returns the terminal; reacquire re-enters modes, re-negotiates features (keyboard enhancement, bracketed paste, mouse), flushes stale input, and resumes events. Suspend and resume hook the same path via signal handling. Panic and drop run the same cleanup.

Status. Planned next in the same private prototype work, behind query routing.

Today. EventStream is a helper thread parked on Crossterm’s shared blocking reader, and tokio::io::stdin is a blocking read on a worker thread that cannot be cancelled. Neither is async terminal I/O; both are wake-up wrappers around blocking reads, which is why they cannot be paused, handed off, or composed with queries safely.

Fixed looks like. On Unix, the terminal device itself is async: a nonblocking file descriptor registered with the runtime’s readiness system, which makes reads genuinely cancellable. Windows needs a different shape, because the console APIs do not offer the same readiness model: an async-shaped API there means worker-backed handles for the current console, with ConPTY for host applications that run child processes — better-scoped workers than today’s shims, not the same mechanism as Unix.

Status. A private prototype has the Unix readiness model working; the Windows worker-backed design is sketched but not validated.

Today. The failure modes catalog is war stories: each entry cites the commit or issue where a real application hit it and worked around it. Nothing prevents the next application from rediscovering each failure, because the failures live in prose rather than in any library’s test suite.

Fixed looks like. The catalog becomes a conformance suite. Every documented failure — the cursor-position race, the lost color reply, the redirected query, the suspend byte injection, the handoff that eats input — is an executable test against the terminal layer, and stays green as the libraries evolve. A fixture corpus of real terminal behavior (exact bytes, per-terminal quirks, support tiers) backs the tests so they do not just encode one emulator’s behavior.

Status. Prototype tests cover the query-routing portion; the handoff and suspend portions are planned alongside those features.

The workstreams above are ordered by dependency, not difficulty:

  1. Design the render/present split in ratatui-core. This is the only gap that needs changes in Ratatui itself, and its outcome shapes everything downstream — watch for a design discussion on the Ratatui repository. Independent of any async work, frames-as-values also improves testing and non-terminal backends.
  2. Finish the protocol foundation. Query routing, session lifecycle, handoff, suspend/resume — each landing with its conformance tests derived from the failure catalog.
  3. Build the thin integration layer. With frames as values and a routed session, the “terminal actor” most large applications hand-roll reduces to a small library: a handle that renders anywhere, presents under the owner, and schedules frames on a budget.
  4. Port a real application. A reference port of a nontrivial async app (for example crates-tui) validates the design: every weakness in its audit should either dissolve into the new API or be a conscious application decision.
  5. Flip the documentation. When the boundaries exist, the Async Applications page becomes the explanation of how the machinery works, and a much shorter tutorial becomes the front door. The failure catalog becomes links into the conformance suite that proves each failure stays fixed.

None of this deprecates the current rules. Applications built on today’s patterns — one owner, bounded channels, drained bursts — remain correct; the goal is that future applications get the same correctness from types instead of discipline.

Design discussions will appear on the Ratatui repository and the Ratatui Discord as the pieces above take shape. The failure catalog on the Async Applications page is the best statement of requirements; if you have hit an async terminal failure that it does not describe, an issue with the details is a direct contribution to the conformance suite.