The terminal renderer was the worst of the burnout, and building my own to replace Ink, which I wrote about in No More Ink, was the first win in climbing back out. Owning what the terminal draws, though, is not the same as being free to change it.
Every visual glitch still meant editing the same React components that drive the agent and hold the transcript, so a change to how something looked could change what the agent did, or corrupt the record of what it had already done. I could not touch the paint without risking the machine underneath it.
That was a bad place to be, because the chat needed a redesign. I wanted hex markers, a distinct treatment for user messages, a composer that grows a border as you type, and deliberate behavior when the terminal is narrow. Under the old structure, every one of those was a change to code that also decided agent behavior and transcript state, and a paint change could cross the one line the tool must never cross: losing or corrupting the transcript.
The tangle
The root cause was that each chat component owned three jobs at once. It decided what to say, how to arrange it in the terminal, and how to color it. One component, three concerns, no boundary between them.
That carried two costs. The visible one was that the look could not change safely, because the paint lived in the same place as the behavior. The quieter one was drift: the plain CLI output and the interactive chat rendered through separate code, free to disagree on tool ordering, wording, and truncation. Two sources of truth for one surface, slowly coming apart.
One way down
The fix was to make the chat a one-way assembly line. Chat state goes in one end, terminal text comes out the other, and each station does one job the next cannot reach back into.
State publishes an honest snapshot of what is true: the messages, whether a turn is running, the composer text, the cursor. No colors, no widths. The next station adds meaning and wording, the hints and outcome labels, still with no pixels in sight. Only then does layout enter, the one station that knows the terminal is this many columns wide. It wraps the text, places the gutters and the caret, and tags each piece with a style role like cursor or muted, a name and never a color. A fixed theme turns those roles into real styles, so cursor becomes inverse video, and the renderer serializes the result to ANSI and prints it.
The rule that holds it together is short: semantics never see pixels, terminal geometry has a single owner, and colors live in exactly one place.
Draw it once
Two more pieces keep the live surface honest. Finished rows print once into the terminal’s own scrollback and are never redrawn; only the live tail, the current turn and the composer, repaints on each keystroke. And typing is pulled out of drawing: a headless input handler owns the keys and the cursor, while the scene only draws the result.
That last split matters more than it looks. The cursor is a piece of layout, not a piece of the input logic, so the handler tracks a logical position and layout maps it to a real coordinate. Editing no longer depends on how the text happens to wrap.
No drift
The payoff is the thing I was after. A redesign now touches only the last two stations, layout and theme, so it cannot reach back and disturb chat or agent behavior. The redesign I wanted is a layout-and-theme change, provably unable to move the parts of Acolyte that must not move.
The second payoff is less obvious and just as useful. The plain CLI output and the interactive chat now share the same layout brain, so they cannot drift: the same tool ordering, headers, and truncation, whatever the surface renders through. One source of truth instead of two.
Built in slices
A migration this deep is where a coding agent earns its place, and where it can do the most damage. So the work landed one small slice at a time, and every slice had to pass the same gate before the next one started. Existing tests stayed immutable; new coverage arrived in its own commit. A change to the look was never allowed to edit an expectation.
The load-bearing check was a parity harness that ran the new pipeline against the old React components across representative states, from the header down to picker suggestions and truncated tool output with diff gutters, comparing the output character for character. It caught divergences a human review would have missed: a stray blank line before queued messages, a help menu laid out column-major instead of row-major, an indentation slip in the header. Dogfooding caught what the harness could not: a cursor that had to land at the end of the line after history recall, a behavior the old code had by accident and no test ever pinned.
Safe to paint
The migration is done. The chat has one semantic input, one owner for terminal geometry, and one renderer boundary, and the legacy layout that used to do all three is gone. Finished rows reach scrollback through the new path, and the plain and interactive surfaces share it.
It took the same discipline as the rest of the recovery: change the structure while the output stays identical, prove it with the parity gate and real use, and save the visible redesign for the end. The paint comes last on purpose. By the time it goes on, nothing it touches can break.