When I started kodr2 over, the proposal had an explicit “what we’re deliberately dropping” list. A TUI was on it, right next to a web server, subagent orchestration, an MCP client, a Docker sandbox, and multi-provider routing. The reasoning wasn’t that any of those were bad ideas. It was that in the original kodr each one was individually reasonable and collectively a maintenance burden - and the 3,400-line monolith always won, because there was no architectural boundary to stop it. So kodr2’s first principle was one job, done well: read a prompt, assemble context, call the model, apply, verify, heal. Not a chat interface. One prompt in, one result out. Dropping the TUI was part of that bet, not a slight against it.
The bet was right. It was also true that the TUI was the thing I missed most.
A one-shot CLI means you craft a whole prompt on the command line, hit enter, watch a scrolling log go by, and then - to change anything - re-invoke with --continue and a fresh incantation. The harness itself was clean. Driving it was tedious. “Not a chat interface” turned out to be a great constraint for the core and a poor experience for the human sitting in front of it.
Not the feature - the boundary
The tempting move is to bolt an interactive mode straight onto the harness. That is exactly how v1 grew its monolith. The reason the old TUI became a burden was never the TUI itself; it was that output was smeared across the codebase as bare process.stdout / process.stderr writes, with no seam between the engine and the screen. Every feature that wanted to say something reached into the same global. Add enough of those and the shape of the thing stops being legible.
So this time I didn’t add the feature first. I added the boundary.
Phase one was a reporter channel. Every place the harness printed something - streamed tokens, tool calls, notices, heartbeats, the run summary - was one of about two dozen direct writes. I replaced them all with a single reporter object: a small, total set of methods (token, toolCall, notice, summary, phase…) that the harness calls, with the implementation chosen once at the edge. A terminal reporter reproduces the old output byte-for-byte. A null reporter is pure no-ops, so --quiet stops being scattered if (!quiet) checks and becomes “select the silent reporter.” And a JSON reporter emits the entire run as newline-delimited events on a new --events flag, for anything downstream that wants to consume a run as data.
The tell that this was the right seam: kodr2 already had exactly one instance of the pattern - the debug logger, a single injected callback that could send raw request/response bytes to disk instead of the terminal. The reporter is that idea generalized from one callback to a handful. Phase one changed no behavior - same bytes on the terminal, same --json summary - and shipped on its own commit. A refactor you can prove is a refactor.
The TUI as a consumer
With the channel in place, the interactive UI is just a fourth reporter.
run() emits the same events it always did. A TUI reporter turns them into render state; a full-screen frame gets painted from that state. It’s a real REPL now: type a prompt, watch the tool calls and the streamed answer land live, then type a follow-up that continues the conversation using the same --continue machinery under the hood. Follow-ups queue instead of racing two runs at once. Commands can gate on a y/N approval before they execute. Assistant Markdown renders to actual bold, italics, and bullets instead of raw asterisks. Repeated notices - the context-window banner that fires every turn - collapse to once per session.
And it’s still zero-dependency. Raw ANSI and node:readline keypresses, no ink, no blessed, because the whole reason kodr exists is to stay honest about complexity. The pure parts - state, rendering, the Markdown pass - are ordinary functions with ordinary unit tests; only the terminal wrangling lives in the imperative shell.
Why this isn’t the old mistake
The difference between this and the v1 creep fits in one sentence: the TUI doesn’t touch the core. It doesn’t reach into the loop, doesn’t special-case the harness, doesn’t add a branch anywhere in run(). It consumes a channel that the plain CLI also consumes. If it broke tomorrow I could delete the tui-* files and the harness wouldn’t notice. Both pieces - the channel and the UI - got a spec before a line of code, so the contract is a file you can read, not folklore you have to reconstruct.
The drop list was never a graveyard, either. Multi-provider routing was on it too, and it already came back once there was a clean provider seam to hang it on. The TUI is the second item to return the same way. That’s the actual lesson from starting over: dropping a feature isn’t a permanent verdict on the feature - it’s a way of admitting you don’t yet have the boundary that would make it cheap. Build the boundary, and the feature falls out of it.
Links:
- kodr2 repo: github.com/paulkohler/kodr2
- Why kodr2 exists: Starting Over, On Purpose
- The last item to come back: multiple providers