Skip to main content

No More Ink

Mar 14, 2026 · 7 min read

Acolyte replaced Ink with a custom React reconciler for terminal rendering. The real reason was not fixing layout bugs. It was owning the input model, because every keybinding and cursor movement is product behavior in a tool people live in.

Acolyte used Ink for its terminal UI from day one. Ink is a solid library that brings React to the terminal with Yoga-powered layout, styled text, and input handling. For most CLI tools, it is the right choice.

As the project grew, Ink started getting in the way more than it helped. The initial breaking point was rendering: duplicated lines, jumping output, and broken scrollback even when using Ink’s Static component correctly. Those were bugs I could not fix without owning the layer. But the real reason to commit to a custom renderer was input.

Input is product behavior

For a terminal-first tool that people live in, input handling is core product surface. Every keybinding, every cursor movement, every modifier combination is something users will notice and rely on. One flaky keybinding, one inconsistent prompt behavior, one weird scroll jump: people feel those immediately. I know because I felt them myself every day while building with Ink.

Ink’s useInput hook gives you a key object, but modifier reporting has gaps. On macOS, Cmd+arrow for line navigation and Alt+arrow for word navigation require specific terminal escape sequences to be parsed correctly. Ink does not handle all of them, so I ended up re-parsing raw escape sequences in the application layer. That created two sources of truth for keyboard input: one in the library, one in my code. That is not a situation I wanted to maintain.

The bigger problem was architectural. With Ink, I had a split model: global chat keybindings, prompt-local editing, picker behavior, and terminal parsing were all separate in a way that made sense when I did not control the UI stack. Multiple input handlers were active at the same time, each guessing whether they should act on a given key event. A lot of TUI bugs come from two handlers both kind of owning the same key.

With a custom renderer, I can turn that into a single input pipeline:

  • Terminal bytes become a normalized key event.
  • The event routes to the currently focused input target.
  • If unhandled, it bubbles to app-level handlers.

The prompt owns text editing. The picker owns navigation when open. The app owns global commands like Ctrl+C and exit. No overlapping handlers, no accidental interactions. The benefit is not less code. It is fewer bugs from unclear ownership.

That is hard to treat as a library detail. If you want precise control over prompt editing, picker focus, interruption, and shortcuts, you need to own the input model.

What I built

The custom renderer keeps the React component model but owns everything below it:

React tree → reconciler → TUI DOM → serialize → terminal output

The surface stays small: Box for layout, Text for styled spans, Static for write-once scrollback, plus useApp and useInput. That is enough for Acolyte’s UI without turning the renderer into a framework.

The reconciler uses React’s react-reconciler package to drive updates against a lightweight DOM tree. Serialization walks the tree, resolves layout, applies ANSI styles, and produces a string. Static items flush once to scrollback and are never re-rendered.

Input handling is centralized in one module. Raw stdin bytes are parsed into structured key events with named flags for every modifier. The parser supports both legacy escape sequences and the Kitty keyboard protocol for terminals that report modifiers unambiguously, with fallback to legacy for everything else. With the input layer consolidated, the application keymap became a pure function: take a key event with named flags, return an action.

The first real bug

The first bug I really noticed was that scrolling did not work. Ink kept repainting the active region, so the terminal never got a stable scrollback buffer. Instead of feeling like a normal terminal app, the UI fought the terminal itself. That was the moment the abstraction stopped being good enough: I could see the symptom clearly, but I could not fix the behavior without owning the rendering model.

A second bug made the same point even more clearly. I was running Acolyte inside Superset, an Electron-based IDE that uses xterm.js as its terminal emulator. Cmd+Backspace was corrupting the input: the cursor position desynced from the displayed text, and the prompt became unusable.

A debug log on the raw stdin bytes showed the cause. Superset sends Cmd+Backspace as two escape sequences packed into a single chunk: a Ctrl+U (clear line) followed by a left arrow. The parser assumed one sequence per chunk. When it received four bytes that matched no single pattern, it fell through to regular text handling and injected garbage.

The fix was to make the parser loop through the buffer and consume one sequence at a time. About thirty minutes from discovery to commit, including tests. That is exactly the kind of issue that would have been impossible to fix inside Ink. With a custom renderer, the problem and the solution lived in the same codebase.

Not settling for “works”

I do not want something that kind of works. That is fine for demos, not for terminal tooling people live in. When someone lives in a tool, they notice everything: one flaky keybinding, one redraw glitch, one weird scroll jump, one inconsistent prompt behavior. These details look secondary on a roadmap, but they strongly affect whether a tool feels inhabitable.

That standard changes the decision-making:

  • Input behavior must be deterministic.
  • Layout must fail predictably.
  • Edge cases are product work, not cleanup.
  • Abstractions must earn their keep.

The main risk is not ambition. It is letting the renderer become open-ended. The scope stays narrow: three primitives, two hooks, one input pipeline. If a feature does not materially improve the UX, it does not go in.

What changed

The dependency graph got smaller: Yoga, Ink, and their transitive dependencies are gone. The input pipeline went from two layers of escape parsing to one. The application no longer needs to know what bytes the terminal sends; it works with named key flags and nothing else. And when something breaks, I can fix it in the same session. No upstream PRs, no workarounds, no waiting.

When this makes sense

For most CLI tools, Ink is still the right choice. It handles layout, styling, and input well enough, and the ecosystem is mature. Build your own only when the framework stops matching your needs and the layer you are replacing is core product behavior. Replacing Ink for styling or layout polish alone is debatable. Replacing it to own the input model is defensible, because that is where product quality lives in a terminal-first tool.

Share

Read next

Beyond the Prompt

The biggest behavior breakthrough in Acolyte did not come from adding more prompt rules. It came from giving the model an explicit way to tell the host when it was done. A tiny lifecycle signal removed a whole class of self-inflicted degradation.