Skip to content
← All posts

Our CLI renderer changed twice in three weeks: off Ink and back again

We left Ink for our own terminal renderer, then moved back a week later: 8,830 lines down to 1,868.

The CLI renderer changed twice in three weeks: Ink → home-grown ANSI (8,830 lines) → forked Ink (1,868 lines)

The Neox CLI started on Ink (React for terminal UIs). On December 15 we decided to replace it with our own ANSI renderer; on December 23 we started moving back to Ink; on January 3, 2026 we deleted the home-grown one entirely.

Two framework changes in three weeks sounds impulsive. Here is why each step happened.

Why we left Ink

In mid-December the CLI had two classic symptoms: timeline entries that should have appeared didn't, and the UI froze and stopped taking input.

At the time we concluded "Ink is unstable". Rereading that analysis now, most of the problems it listed were ours:

  • Fighting Ink for stdin. After every rerender we manually called stdin.resume() + setRawMode(true), and the input component used setTimeout to restore it again after every render. Ink manages pausing and resuming stdin internally, so the two fought. With many renders per second during streaming, there was always some window where stdin was paused and nobody resumed it, and keypresses vanished.
  • Synchronous blocking. Pasting an image read the clipboard with spawnSync / execSync. A timeout doesn't make it asynchronous; the event loop stalled.
  • Misusing Static. Ink's <Static> means "render once, never update". Our timeline had several event sources (text, thinking, tools, file streams); once an entry was wrongly judged "finished" and moved into Static, it never updated again.

The conclusion said "the system keeps patching framework behavior, it's getting mystical, we should change the rendering architecture". So we wrote our own ANSI renderer: scroll regions, a pinned bottom bar, a screen buffer, a render scheduler — all ours.

A week of home-grown ANSI

The custom version ran quickly and looked closer to what we wanted. But the freezes from our previous post — no input after idle, the signal storm, crashing when the terminal detached — all showed up during that week.

On December 23 we counted the custom code:

ModuleLines
Main controller2,800
Input management (incl. recovery)2,100
Bottom bar + input box1,500
Timeline rendering850
Markdown rendering500
Everything else (buffer, layout, scheduling)1,080
Totalabout 8,830

Input management was 2,100 lines, mostly health checks and three-level recovery. We left Ink because "stdin was hard to manage", then wrote over 2,000 lines to manage stdin ourselves — and still didn't get it right.

Back to Ink, without the fight

One principle for the move back: Ink owns stdin; we don't touch it. We deleted every "force stdin back after render" snippet and made clipboard access asynchronous.

After the move, the CLI layer went from 8,830 lines to 1,868. The 2,000-plus lines of recovery and health checks simply disappeared.

Going back had its own problems, mainly two:

Where Static ends. The first migration put every entry that wasn't streaming into Static, and the message the user had just typed didn't show — a new entry placed into Static had already missed its render. The final split: only entries that are truly finished and will never change go into Static; user messages, streaming replies and running tool calls stay in the dynamic area. Keeping the frequently refreshing spinner and status bar in the dynamic area also fixed the whole screen flickering every time the status bar spun.

A memory leak. On December 24 we noticed memory climbing over long sessions until the system froze. Two causes: the Static component recreated all its children on every render with no memoization, and the queue of entries waiting to go into Static had no limit. We memoized the former and capped the latter, forcing a render and clear when it fills.

The first was a bug in Ink itself, so we forked Ink into our repo and maintain it ourselves, fixing it inside Ink rather than working around it in our own code.

On December 24 the Ink version became the default; on January 3 we deleted the ANSI version.

Conclusion

Re-examining why we left Ink, we found at least half the listed problems came from us fighting Ink for control of stdin rather than from Ink being unstable; the 2,100 lines of input recovery in the custom version showed the problem had never been solved.

Moving back, we gave Ink full ownership of stdin and the CLI code went from 8,830 lines to 1,868; Ink's own memory issue we fixed directly in our fork. After the migration, that batch of freezes did not come back.

View source