diffium-db: A live diff of what agents do to your database
> The following post is a recap of my Claude Code sessions on building diffium-db. Although the content is AI-generated, I go through the entire post to see if any differences occur - no, everything is through my own process of developing diffium-db. If you'd like to discuss anything in relation to this project directly with me, you can do so on dennis@a2w.io or on x.com/@kuberdenis.
>
> Note 2: this project is a direct continuation of another project of mine that I use to watch diffs - github.com/kubeden/diffium.
I let agents run migrations. That is how the work goes now. You describe what you want, something writes the SQL, you read it, you approve it, and a few seconds later the schema is different. The ten minutes after that are the problem.
git diff shows you the migration file. It does not show you what the migration did. And it shows you nothing at all about the row an agent updated on the way past, because updating it seemed reasonable at the time.
There are good schema diff tools, but they mostly work the same way. You run them afterwards, against two databases, and they hand you a delta. Nothing sits open next to you while the work is happening.
So I built one. diffium-db is a terminal UI, and it is meant to stay open while you work.
Resources
- kubeden/diffium-db, the repo (MIT)
- kubeden/diffium, the file-diff TUI this one is modelled on
- OpenTUI, the terminal framework it is built with
- * *
What it does
- * *
You point it at a Postgres, take a baseline, and leave the watcher open. From that moment everything that changed is listed on the left, the change itself is on the right, and it re-reads the database once a second.
Here it is catching a migration. Two columns and an index appeared on demo.users while I was looking at it:
!denislavgavrilov.com
The left pane is everything that changed since the baseline. The right pane is the object you have selected, before and after.
It watches tables (columns, defaults, identity, constraints, indexes, triggers), views, materialized views, enums and functions. It also watches rows. That turned out to be the interesting half, and I will come back to it.
There are four commands. watch opens the TUI, snapshot takes a baseline, baselines lists the ones you have, and diff prints the changes and exits. That last one takes --exit-code, so it returns 1 when anything changed, which is enough to fail a CI job or stop an agent's loop before it does the next thing.
- * *
Setting it up
- * *
You need Bun 1.3 or newer and a Postgres to watch. There is exactly one dependency past that, @opentui/core, so the install is quick.
git clone kubeden/diffium-db && cd diffium-db
bun install
export DATABASE_URL='postgresql://...'
Then take a baseline and open the watcher:
bun run src/index.ts snapshot
bun run src/index.ts watch
That is the whole setup. The repo ships a demo so you can see it move without waiting for an agent to do something. examples/demo/01-baseline.sql builds a small demo schema with users and projects in it, and examples/demo/02-agent-change.sql is what an agent does when you tell it "add billing plans and clean up".
Two terminals. In the first:
psql "$DATABASE_URL" -f examples/demo/01-baseline.sql
bun run src/index.ts snapshot --schema demo
bun run src/index.ts watch --schema demo
In the second, while the watcher is open:
psql "$DATABASE_URL" -f examples/demo/02-agent-change.sql
Five changes land on the screen: a new enum, two new columns and a new index on demo.users, an index gone from demo.projects, one row inserted and one row deleted. j and k walk them, s switches between side by side and inline, e writes the whole diff to a file.
- * *
Where to point it
- * *
The demo is a demo. For real work, do not point it at production. The point is to let the agent run and read what it did afterwards, and you want that happening somewhere you can throw away.
So I give the agent a branch. On Neon that is two commands, plus the project id, because I have more than one project and the CLI refuses to guess:
neonctl branches create --project-id <project> --name agent-run
neonctl connection-string agent-run --project-id <project>
Point DATABASE_URL at what the second one prints, take your baseline there, and let the agent work on the branch. The branch is copy-on-write off its parent, so it comes up holding the same schema and the same rows as the database you care about. The one I made while writing this took 1.2 seconds. An idle branch suspends its own compute too, so the ones you forget to delete are not sitting there running.
Then you read the diff. If the migration is what you wanted, run it against the real database. If the agent did something you did not expect, you found that out on a copy, and the copy gets deleted.
That is the loop, and it is how this post was made. The demo and every screenshot in it ran on a branch of the demo project. The demo database itself still holds the state I left it in.
Any Postgres works and nothing in the tool is Neon-specific. Branching is what makes this cheap enough to do every time instead of only when you remember.
- * *
What a row diff can prove
- * *
Structure is the easy half. Rows are where it gets uncomfortable.
To tell you that a row changed, you have to remember what it was. Storing every row is not an option for anything real, so diffium-db stores a fingerprint instead: the primary key, plus md5(t::text) of the whole record, plus a short preview. Cheap to compute in the database, cheap to keep, and it separates an insert from an update from a delete exactly. If the key is new, it is an insert. If the key is gone, it is a delete. If the key is the same and the hash moved, someone edited that row.
That works right up until the table gains a column. Then every hash in the table changes at once, and a naive tool would tell you that every row in it was edited. Nobody edited them. The shape moved underneath them.
diffium-db does not do that.
!denislavgavrilov.com
~3? in the left pane, and the note at the top of the diff. It detected the column change, marked the table, and told me which half of its own answer to trust.
The count is written ~3? and not ~3, and the first line of the diff says why. Two columns arrived, so every row in the table reads as edited, and the update number is the one part of that answer the tool cannot stand behind.
Inserts and deletes are still exact, because those come from the primary key and the key does not care what the shape is. Only the update count is guesswork, and printing a guess plain, next to numbers it can prove, would make the whole screen worth less.
Tables without a primary key, and tables over --row-limit (5000 by default), get a row count and no claims at all. Same principle. I would rather it say less.
- * *
Structure is text
- * *
The structural diff rests on one decision, and it explains a lot of the behaviour.
Every object renders to one canonical block of text with a deterministic line order. A table becomes its columns, then its constraints, then its indexes, then its triggers, always in that order. The structural diff is then just a line diff of those blocks.
That one choice pays for a lot. Adding a column is one added line instead of a table that vaguely "changed", and side by side, the +2 -0 counts and horizontal scrolling through a long default all come free.
!denislavgavrilov.com
The same change inline instead of side by side, so nothing is cut off at the pane edge. s toggles it, w toggles wrapping, and both are remembered between runs.
There is a cost. diffium-db tells you what the database looks like now, not the DDL statement that would get you there. For a watcher that is the right side of the trade. If you want generated migrations, you want a different program.
- * *
Where the baseline lives
- * *
A baseline is a stored value, not a live connection, so you can take one now and diff against it tomorrow, keep several named ones, or commit one next to the migration that produced it.
By default they go in .diffium-db/snapshots/ as JSON. Pass --store neon --store-url <url> and they go into a diffium_db schema in Postgres instead. Use that when CI and your laptop need to agree on what "before" means. The watcher never looks at its own schema, so the store URL and the watched URL can be the same database.
With the branch above, that means the baseline can sit on the branch itself, one schema over from the thing it is describing. The branch is then the entire experiment, and deleting it takes the record with it.
- * *
Future work
- * *
What v1 does not do, roughly in the order I want to fix it.
No ORM awareness. v1 tells you a column appeared. It cannot tell you which migration file or which model definition put it there. That mapping is an ORM's business and it is the next thing I want. src/orms/ is where it goes, documented and deliberately empty.
Postgres only. The catalog reading is isolated in src/pg/, and nothing above that boundary knows what a catalog is, so a second dialect has an obvious place to go. It does not exist yet.
No sequences. Every bigserial makes one and they move on every insert. Pure noise until someone asks.
Polling, not replication. It re-reads once a second. On a large database logical replication would be much cheaper, and that is the obvious thing to do if polling ever starts to hurt.
One thing is not on that list and never will be. diffium-db does not write to the database it is watching. The only thing it writes is its own store, and only if you point it at one.
- * *
Closing
- * *
It is about 2,200 lines of source and 900 of tests. 64 of those tests run with no database at all, and that includes the terminal ones, because OpenTUI will render into memory and let you assert on the screen it drew. So a change to the layout breaks a test instead of quietly looking wrong in a screenshot I forgot to retake.
What I keep coming back to is the row diff. It would have been easier to print a number for every table and let people assume it meant something. The version that says "I cannot prove this one" is a bit uglier to look at. I will take it. A thing that sits next to an agent and reports on what it did has to be more careful than the agent is.
It is on GitHub under MIT. If you try it and it tells you something wrong, open an issue.
Thank you for reading.
1 reaction
Terminal UIs for Harnesses in 2026: who builds on what, and why
> We spent forty years escaping the command line. Then AI agents arrived, and the best-funded interface teams in software went back to a grid of monospaced characters. So what's happened?
The terminal came back beautiful
In 1984 the Macintosh promised we would never look at a blinking cursor again. In 2026, OpenAI, Anthropic, Google, and xAI all ship their flagship coding products as terminal apps. Not grudgingly, either. These are polished, animated, mouse-aware interfaces that happen to be drawn entirely out of text. Something strange happened on the way to the future, and it is worth understanding what.
A screen made of cells
!Diagram titled How a terminal actually works: a stream of bytes with escape codes is interpreted into a grid of fixed-width cells, where colors come from the codes, wide characters take two cells, and the cursor is one cell painted in reverse.
A terminal gives a program almost nothing. There is no canvas, no DOM, no pixels. There is a grid of monospaced character cells, and two streams of bytes: one going out, one coming in. Print text and it simply fills the next cells in the grid.
Everything else is a trick. The trick is called an escape sequence: a run of bytes starting with the escape character that the terminal reads as a command instead of text. \x1b[5;10H moves the cursor to row 5, column 10. \x1b[31m turns the next characters red. Other sequences clear lines, hide the cursor, or make mouse clicks arrive on the input stream as encoded bytes. An app drawing itself is just a program printing instructions; those are the boxed bytes in fig 01.
A full-screen app adds two more moves. It switches the input to raw mode, so each keystroke arrives instantly instead of waiting for Enter; that is why a single j moves the cursor in vim. And it opens the alternate screen buffer, a second blank screen to draw on. Quit vim and your shell history is exactly where you left it. That is the alternate screen closing.
So every TUI framework ever written has the same job. Hold a model of the grid in memory. Turn your interface into cells. Compare that with what is already on screen, and emit the fewest escape sequences that make the two match. It is the same paint, diff, and flush loop as a game engine, or as React against the DOM. The whole craft lives in that loop, and in one cursed detail: a Japanese character is two cells wide, an emoji is anyone's guess, and every miscount shows up as a broken border.
Why we left, and what pulled us back
We left because the terminal of 1985 deserved to be left. Sixteen colors on a good day. No mouse. No images. The GUI won on fidelity, then the web won on reach, and the terminal became the boiler room: always there, never decorated.
!Timeline titled Where the interface investment went: terminal era from the 1978 VT100, the GUI era and the web from the 1984 Macintosh, and the return with GPU terminals in 2022 and AI coding agents in 2025-26.
What pulled us back was not nostalgia. It was AI agents, and three unsentimental facts about where their work happens.
First, the terminal is the one interface that already exists everywhere an agent works: every server, every SSH session, every CI box. Second, it composes. Pipes, logs, and replayable event journals are native citizens, and an agent's output is exactly that kind of material. Third, it is cheap to render. A status row updating thirty times a second costs nothing, which matters when the interesting part of your product is the model, not the chrome.
A chat web app shows you what an agent says. The terminal is where the agent's actual work already lives. Putting the interface there removes a whole layer of translation.
The hardware quietly got good
The return would have looked terrible on the terminal we abandoned. It looks good now because the substrate improved while nobody was watching.
Color came first. Two shades became sixteen, then 256, then 24-bit truecolor: 16.7 million colors per cell, standard in every serious emulator today.
!Step chart titled Colors one terminal cell can show: 2 in 1978, 16 in the 1980s, 256 in 1999, 16.7 million truecolor in the 2010s.
Then came the protocols that fixed the old embarrassments. Synchronized output lets an app wrap a whole frame in a pair of escape sequences so the terminal paints it atomically. No flicker, no tearing. Support landed in VS Code's terminal and tmux this year, work driven in part by Claude Code's anti-flicker push. The kitty graphics protocol transmits real pixels, and Ghostty, WezTerm, Konsole, iTerm2, and Warp all speak it. Where it is missing, there is a lovely fallback: the half-block character ▀, with the foreground color as the top pixel and the background as the bottom. Two pixels per cell, on any terminal made since the Reagan administration.
!Diagram titled Two pixels per character cell: the half-block glyph draws two image pixels in one cell, foreground for the top pixel and background for the bottom, the image fallback used by chafa, viu, timg, ratatui-image and OpenTUI.
Add GPU-rendered emulators like Ghostty, and beauty stopped being impossible. It just needed a reason. Millions of developers staring at agent harnesses all day turned out to be the reason.
What the AI coding tools run on
Look at what the major coding agents actually ship, and the field splits into camps.
!Bar chart titled AI coding CLIs by TUI stack, August 2026: Ink and React family 5, Rust and ratatui 2, plain REPL 2, OpenTUI and Solid 1, Go and Bubble Tea 1.
| Tool | Stack | Notes |
|---|---|---|
| Claude Code | TS, React, forked Ink | custom reconciler, 140+ components |
| Codex CLI | Rust + ratatui | rewrote from TS + Ink in 2025 |
| Gemini CLI | TS, React 19 + forked Ink | open source |
| Grok Build | Rust + ratatui | open-sourced July 2026 |
| opencode | OpenTUI + SolidJS | was Go + Bubble Tea |
| Crush | Go + Bubble Tea v2 | the original opencode lineage |
| Aider | prompt_toolkit + Rich | a REPL by choice |
Half that chart says Ink, so Ink deserves a proper introduction. Ink is a renderer that points React at the terminal instead of the browser. You write ordinary React components with hooks and state, lay them out with flexbox through Yoga, the same layout engine React Native uses, and Ink turns the tree into styled lines of text, re-printing what changed. Its pull is simple: millions of developers already think in React, and Ink lets them keep thinking in it. Its limits are just as simple: it is pure JavaScript, line-oriented, capped around 30 frames per second, with no mouse support and no images.
Those limits explain the table's real story. The biggest Ink users all ended up forking Ink rather than using it stock; Claude Code's build carries a custom React reconciler and its own layout engine. And OpenAI threw away a working TypeScript app to rewrite Codex in Rust on ratatui. When teams this well resourced keep rebuilding the same layer by hand, the layer is missing from the ecosystem.
OpenTUI is the most direct answer to that gap, and its origin is the point. The team behind opencode (Anomaly, formerly the SST crew) started with a Go interface built on Bubble Tea, but wanted the whole product in TypeScript. Nothing in the JS world could draw fast enough, so they built their own framework and rebuilt opencode on it. OpenTUI keeps React's programming model but replaces Ink's machinery: a native rendering core written in Zig holds the screen as a buffer of cells, diffs each frame, and repaints only what changed. That buys the things Ink cannot do: no frame cap, full mouse support, real images over the kitty graphics protocol with half-block fallback. It is MIT-licensed, documented at opentui.com, runs opencode in production today, and is the closest thing the TypeScript terminal world has to a common foundation forming in real time.
The other camps are healthy too. Charm shipped Bubble Tea v2 in February, hardened inside Crush. Rust has ratatui, and Python has Textual. Nobody is starving; the fight is over which model wins the next decade of terminal software.
The parts everyone rebuilds
Here is the gap all those forks point at. Chat components exist in every ecosystem: message bubbles, spinners, markdown renderers. But a harness is not a chat. A harness needs a status row that proves the agent is alive and tells you what this turn costs. An approval prompt that gates a real action on your real machine, with the safe option first. A home screen that says which model is pointed at which screen. Nobody ships those, so every team on that chart has built them from scratch, and several built them wrong in the same off-by-one-cell ways.
A copy-paste culture is forming around the problem, in the mold of shadcn/ui: registries like termcn, TermUI, and InkUI distribute terminal component source you own and edit. Vercel's @ai-sdk/tui attacks the same space from the SDK side.
---
I got tired of rebuilding the harness parts too, so I decided to open source the parts I use. agentparts is a small library of exactly these components, built on OpenTUI: the turn status row, the approval prompt, the shell layout, the prompt box. All the width arithmetic lives in a renderer-free core that runs on plain Node. Feel free to use for your harnesses!
!Screenshot of AgentParts, terminal components for agent harnesses: model and policy info, a list of recent agent sessions, and a prompt box with key hints.
> >> arthurkatcher/agentparts
---
So you want to build one
Start with an honest question: does this need to be full-screen at all? Aider and Goose are proof that a well-formatted REPL can carry a serious product. If your interface is a conversation with occasional structure, print good output and stop. Reach for a TUI framework when you need layout: panes, status rows, things that update in place.
!Decision tree titled Choosing a TUI stack: if it doesn't need the whole screen, a REPL with good output; if it does, a full-screen framework by language: Ink or OpenTUI for TypeScript, ratatui for Rust, Bubble Tea v2 for Go, Textual for Python.
If you do need one, the choice is mostly made by the language you already live in:
| You write | Reach for | Why |
|---|---|---|
| TypeScript | Ink, or OpenTUI | Ink is mature; OpenTUI adds speed, mouse, images |
| Rust | ratatui + crossterm | the default; start from ratatui/templates |
| Go | Bubble Tea v2 + Bubbles | best component library, Lip Gloss for styling |
| Python | Textual | CSS-like styling, rich widget gallery |
The beautiful part is not the framework. It is discipline about the medium:
Design a palette, then design its absence. Pick a handful of truecolor values on purpose, and decide what the interface looks like with color stripped away: plainly unstyled, never broken. Honor NO_COLOR, the environment variable users set when they want none. Get width right. Test with CJK text and emoji, and snapshot your layout at 40, 80, and 132 columns, because every TUI bug is an off-by-one-cell bug. Never flicker. Wrap each frame in synchronized output, the escape pair that tells the terminal to paint it in one go, and diff before you draw. Shed, don't squish. When the window gets narrow, drop the least important element entirely instead of truncating everything a little.
And steal the parts that already exist: the spinners, the status rows, the prompts. Bubbles has them in Go, the registries have them in TypeScript. The chrome is the least differentiated thing you will build, which is exactly why you should not build it twice.
Step back far enough and the cycle stops looking like a cycle. We did not return to the terminal we left. We brought everything back with us: React reconcilers running flexbox, CSS-like styling, component registries, GPU rendering, twenty years of interface craft applied to a character grid. The real trade was never terminal versus GUI. It is fidelity versus leverage, how rich an interface can be against how many places it can reach, and right now leverage is winning.
Good design finally reached the terminal, one escape sequence at a time.
---
Originally published on arthurkatcher.com.
1 reaction
Mentions
It doesn't, that's anomalyco/opentui.
diffium-db: A live diff of what agents do to your database > The following post is a recap of my Claude Code sessions on building diffium-db. Although the content is AI-generated, I go through the entire post to see if any differences occur - no, everything is through my own process of developing diffium-db. If you'd like to discuss anything in relation to this project directly with me, you can do so on dennis@a2w.io or on x.com/@kuberdenis. > > Note 2: this project is a direct continuation of another project of mine that I use to watch diffs - github.com/kubeden/diffium. I let agents run migrations. That is how the work goes now. You describe what you want, something writes the SQL, you read it, you approve it, and a few seconds later the schema is different. The ten minutes after that are the problem. git diff shows you the migration file. It does not show you what the migration did. And it shows you nothing at all about the row an agent updated on the way past, because updating it seemed reasonable at the time. There are good schema diff tools, but they mostly work the same way. You run them afterwards, against two databases, and they hand you a delta. Nothing sits open next to you while the work is happening. So I built one. diffium-db is a terminal UI, and it is meant to stay open while you work. Resources - kubeden/diffium-db, the repo (MIT) - kubeden/diffium, the file-diff TUI this one is modelled on - OpenTUI, the terminal framework it is built with - * * What it does - * * You point it at a Postgres, take a baseline, and leave the watcher open. From that moment everything that changed is listed on the left, the change itself is on the right, and it re-reads the database once a second. Here it is catching a migration. Two columns and an index appeared on demo.users while I was looking at it: !denislavgavrilov.com The left pane is everything that changed since the baseline. The right pane is the object you have selected, before and after. It watches tables (columns, defaults, identity, constraints, indexes, triggers), views, materialized views, enums and functions. It also watches rows. That turned out to be the interesting half, and I will come back to it. There are four commands. watch opens the TUI, snapshot takes a baseline, baselines lists the ones you have, and diff prints the changes and exits. That last one takes --exit-code, so it returns 1 when anything changed, which is enough to fail a CI job or stop an agent's loop before it does the next thing. - * * Setting it up - * * You need Bun 1.3 or newer and a Postgres to watch. There is exactly one dependency past that, @opentui/core, so the install is quick. git clone kubeden/diffium-db && cd diffium-db bun install export DATABASE_URL='postgresql://...' Then take a baseline and open the watcher: bun run src/index.ts snapshot bun run src/index.ts watch That is the whole setup. The repo ships a demo so you can see it move without waiting for an agent to do something. examples/demo/01-baseline.sql builds a small demo schema with users and projects in it, and examples/demo/02-agent-change.sql is what an agent does when you tell it "add billing plans and clean up". Two terminals. In the first: psql "$DATABASE_URL" -f examples/demo/01-baseline.sql bun run src/index.ts snapshot --schema demo bun run src/index.ts watch --schema demo In the second, while the watcher is open: psql "$DATABASE_URL" -f examples/demo/02-agent-change.sql Five changes land on the screen: a new enum, two new columns and a new index on demo.users, an index gone from demo.projects, one row inserted and one row deleted. j and k walk them, s switches between side by side and inline, e writes the whole diff to a file. - * * Where to point it - * * The demo is a demo. For real work, do not point it at production. The point is to let the agent run and read what it did afterwards, and you want that happening somewhere you can throw away. So I give the agent a branch. On Neon that is two commands, plus the project id, because I have more than one project and the CLI refuses to guess: neonctl branches create --project-id <project> --name agent-run neonctl connection-string agent-run --project-id <project> Point DATABASE_URL at what the second one prints, take your baseline there, and let the agent work on the branch. The branch is copy-on-write off its parent, so it comes up holding the same schema and the same rows as the database you care about. The one I made while writing this took 1.2 seconds. An idle branch suspends its own compute too, so the ones you forget to delete are not sitting there running. Then you read the diff. If the migration is what you wanted, run it against the real database. If the agent did something you did not expect, you found that out on a copy, and the copy gets deleted. That is the loop, and it is how this post was made. The demo and every screenshot in it ran on a branch of the demo project. The demo database itself still holds the state I left it in. Any Postgres works and nothing in the tool is Neon-specific. Branching is what makes this cheap enough to do every time instead of only when you remember. - * * What a row diff can prove - * * Structure is the easy half. Rows are where it gets uncomfortable. To tell you that a row changed, you have to remember what it was. Storing every row is not an option for anything real, so diffium-db stores a fingerprint instead: the primary key, plus md5(t::text) of the whole record, plus a short preview. Cheap to compute in the database, cheap to keep, and it separates an insert from an update from a delete exactly. If the key is new, it is an insert. If the key is gone, it is a delete. If the key is the same and the hash moved, someone edited that row. That works right up until the table gains a column. Then every hash in the table changes at once, and a naive tool would tell you that every row in it was edited. Nobody edited them. The shape moved underneath them. diffium-db does not do that. !denislavgavrilov.com ~3? in the left pane, and the note at the top of the diff. It detected the column change, marked the table, and told me which half of its own answer to trust. The count is written ~3? and not ~3, and the first line of the diff says why. Two columns arrived, so every row in the table reads as edited, and the update number is the one part of that answer the tool cannot stand behind. Inserts and deletes are still exact, because those come from the primary key and the key does not care what the shape is. Only the update count is guesswork, and printing a guess plain, next to numbers it can prove, would make the whole screen worth less. Tables without a primary key, and tables over --row-limit (5000 by default), get a row count and no claims at all. Same principle. I would rather it say less. - * * Structure is text - * * The structural diff rests on one decision, and it explains a lot of the behaviour. Every object renders to one canonical block of text with a deterministic line order. A table becomes its columns, then its constraints, then its indexes, then its triggers, always in that order. The structural diff is then just a line diff of those blocks. That one choice pays for a lot. Adding a column is one added line instead of a table that vaguely "changed", and side by side, the +2 -0 counts and horizontal scrolling through a long default all come free. !denislavgavrilov.com The same change inline instead of side by side, so nothing is cut off at the pane edge. s toggles it, w toggles wrapping, and both are remembered between runs. There is a cost. diffium-db tells you what the database looks like now, not the DDL statement that would get you there. For a watcher that is the right side of the trade. If you want generated migrations, you want a different program. - * * Where the baseline lives - * * A baseline is a stored value, not a live connection, so you can take one now and diff against it tomorrow, keep several named ones, or commit one next to the migration that produced it. By default they go in .diffium-db/snapshots/ as JSON. Pass --store neon --store-url <url> and they go into a diffium_db schema in Postgres instead. Use that when CI and your laptop need to agree on what "before" means. The watcher never looks at its own schema, so the store URL and the watched URL can be the same database. With the branch above, that means the baseline can sit on the branch itself, one schema over from the thing it is describing. The branch is then the entire experiment, and deleting it takes the record with it. - * * Future work - * * What v1 does not do, roughly in the order I want to fix it. No ORM awareness. v1 tells you a column appeared. It cannot tell you which migration file or which model definition put it there. That mapping is an ORM's business and it is the next thing I want. src/orms/ is where it goes, documented and deliberately empty. Postgres only. The catalog reading is isolated in src/pg/, and nothing above that boundary knows what a catalog is, so a second dialect has an obvious place to go. It does not exist yet. No sequences. Every bigserial makes one and they move on every insert. Pure noise until someone asks. Polling, not replication. It re-reads once a second. On a large database logical replication would be much cheaper, and that is the obvious thing to do if polling ever starts to hurt. One thing is not on that list and never will be. diffium-db does not write to the database it is watching. The only thing it writes is its own store, and only if you point it at one. - * * Closing - * * It is about 2,200 lines of source and 900 of tests. 64 of those tests run with no database at all, and that includes the terminal ones, because OpenTUI will render into memory and let you assert on the screen it drew. So a change to the layout breaks a test instead of quietly looking wrong in a screenshot I forgot to retake. What I keep coming back to is the row diff. It would have been easier to print a number for every table and let people assume it meant something. The version that says "I cannot prove this one" is a bit uglier to look at. I will take it. A thing that sits next to an agent and reports on what it did has to be more careful than the agent is. It is on GitHub under MIT. If you try it and it tells you something wrong, open an issue. Thank you for reading.
CoreWar TUI Vibe coded this implementation of corewar. Just based it on the spec at corewar-docs.readthedocs.io and built the TUI using OpenTUI (anomalyco/opentui) Corewar is an older game that lets you battle little programs (called warriors) against one another in a "core". They execute instructions written in a small language called redcode (kind of like assembly). Super small instruction set. I went down a rabbit hole this week trying to make some warriors and thought I'd at least use Claude to build a good TUI for it. Just kinda scratches the itch in my brain for some reason.
Terminal UIs for Harnesses in 2026: who builds on what, and why > We spent forty years escaping the command line. Then AI agents arrived, and the best-funded interface teams in software went back to a grid of monospaced characters. So what's happened? The terminal came back beautiful In 1984 the Macintosh promised we would never look at a blinking cursor again. In 2026, OpenAI, Anthropic, Google, and xAI all ship their flagship coding products as terminal apps. Not grudgingly, either. These are polished, animated, mouse-aware interfaces that happen to be drawn entirely out of text. Something strange happened on the way to the future, and it is worth understanding what. A screen made of cells !Diagram titled How a terminal actually works: a stream of bytes with escape codes is interpreted into a grid of fixed-width cells, where colors come from the codes, wide characters take two cells, and the cursor is one cell painted in reverse. A terminal gives a program almost nothing. There is no canvas, no DOM, no pixels. There is a grid of monospaced character cells, and two streams of bytes: one going out, one coming in. Print text and it simply fills the next cells in the grid. Everything else is a trick. The trick is called an escape sequence: a run of bytes starting with the escape character that the terminal reads as a command instead of text. \x1b[5;10H moves the cursor to row 5, column 10. \x1b[31m turns the next characters red. Other sequences clear lines, hide the cursor, or make mouse clicks arrive on the input stream as encoded bytes. An app drawing itself is just a program printing instructions; those are the boxed bytes in fig 01. A full-screen app adds two more moves. It switches the input to raw mode, so each keystroke arrives instantly instead of waiting for Enter; that is why a single j moves the cursor in vim. And it opens the alternate screen buffer, a second blank screen to draw on. Quit vim and your shell history is exactly where you left it. That is the alternate screen closing. So every TUI framework ever written has the same job. Hold a model of the grid in memory. Turn your interface into cells. Compare that with what is already on screen, and emit the fewest escape sequences that make the two match. It is the same paint, diff, and flush loop as a game engine, or as React against the DOM. The whole craft lives in that loop, and in one cursed detail: a Japanese character is two cells wide, an emoji is anyone's guess, and every miscount shows up as a broken border. Why we left, and what pulled us back We left because the terminal of 1985 deserved to be left. Sixteen colors on a good day. No mouse. No images. The GUI won on fidelity, then the web won on reach, and the terminal became the boiler room: always there, never decorated. !Timeline titled Where the interface investment went: terminal era from the 1978 VT100, the GUI era and the web from the 1984 Macintosh, and the return with GPU terminals in 2022 and AI coding agents in 2025-26. What pulled us back was not nostalgia. It was AI agents, and three unsentimental facts about where their work happens. First, the terminal is the one interface that already exists everywhere an agent works: every server, every SSH session, every CI box. Second, it composes. Pipes, logs, and replayable event journals are native citizens, and an agent's output is exactly that kind of material. Third, it is cheap to render. A status row updating thirty times a second costs nothing, which matters when the interesting part of your product is the model, not the chrome. A chat web app shows you what an agent says. The terminal is where the agent's actual work already lives. Putting the interface there removes a whole layer of translation. The hardware quietly got good The return would have looked terrible on the terminal we abandoned. It looks good now because the substrate improved while nobody was watching. Color came first. Two shades became sixteen, then 256, then 24-bit truecolor: 16.7 million colors per cell, standard in every serious emulator today. !Step chart titled Colors one terminal cell can show: 2 in 1978, 16 in the 1980s, 256 in 1999, 16.7 million truecolor in the 2010s. Then came the protocols that fixed the old embarrassments. Synchronized output lets an app wrap a whole frame in a pair of escape sequences so the terminal paints it atomically. No flicker, no tearing. Support landed in VS Code's terminal and tmux this year, work driven in part by Claude Code's anti-flicker push. The kitty graphics protocol transmits real pixels, and Ghostty, WezTerm, Konsole, iTerm2, and Warp all speak it. Where it is missing, there is a lovely fallback: the half-block character ▀, with the foreground color as the top pixel and the background as the bottom. Two pixels per cell, on any terminal made since the Reagan administration. !Diagram titled Two pixels per character cell: the half-block glyph draws two image pixels in one cell, foreground for the top pixel and background for the bottom, the image fallback used by chafa, viu, timg, ratatui-image and OpenTUI. Add GPU-rendered emulators like Ghostty, and beauty stopped being impossible. It just needed a reason. Millions of developers staring at agent harnesses all day turned out to be the reason. What the AI coding tools run on Look at what the major coding agents actually ship, and the field splits into camps. !Bar chart titled AI coding CLIs by TUI stack, August 2026: Ink and React family 5, Rust and ratatui 2, plain REPL 2, OpenTUI and Solid 1, Go and Bubble Tea 1. | Tool | Stack | Notes | |---|---|---| | Claude Code | TS, React, forked Ink | custom reconciler, 140+ components | | Codex CLI | Rust + ratatui | rewrote from TS + Ink in 2025 | | Gemini CLI | TS, React 19 + forked Ink | open source | | Grok Build | Rust + ratatui | open-sourced July 2026 | | opencode | OpenTUI + SolidJS | was Go + Bubble Tea | | Crush | Go + Bubble Tea v2 | the original opencode lineage | | Aider | prompt_toolkit + Rich | a REPL by choice | Half that chart says Ink, so Ink deserves a proper introduction. Ink is a renderer that points React at the terminal instead of the browser. You write ordinary React components with hooks and state, lay them out with flexbox through Yoga, the same layout engine React Native uses, and Ink turns the tree into styled lines of text, re-printing what changed. Its pull is simple: millions of developers already think in React, and Ink lets them keep thinking in it. Its limits are just as simple: it is pure JavaScript, line-oriented, capped around 30 frames per second, with no mouse support and no images. Those limits explain the table's real story. The biggest Ink users all ended up forking Ink rather than using it stock; Claude Code's build carries a custom React reconciler and its own layout engine. And OpenAI threw away a working TypeScript app to rewrite Codex in Rust on ratatui. When teams this well resourced keep rebuilding the same layer by hand, the layer is missing from the ecosystem. OpenTUI is the most direct answer to that gap, and its origin is the point. The team behind opencode (Anomaly, formerly the SST crew) started with a Go interface built on Bubble Tea, but wanted the whole product in TypeScript. Nothing in the JS world could draw fast enough, so they built their own framework and rebuilt opencode on it. OpenTUI keeps React's programming model but replaces Ink's machinery: a native rendering core written in Zig holds the screen as a buffer of cells, diffs each frame, and repaints only what changed. That buys the things Ink cannot do: no frame cap, full mouse support, real images over the kitty graphics protocol with half-block fallback. It is MIT-licensed, documented at opentui.com, runs opencode in production today, and is the closest thing the TypeScript terminal world has to a common foundation forming in real time. The other camps are healthy too. Charm shipped Bubble Tea v2 in February, hardened inside Crush. Rust has ratatui, and Python has Textual. Nobody is starving; the fight is over which model wins the next decade of terminal software. The parts everyone rebuilds Here is the gap all those forks point at. Chat components exist in every ecosystem: message bubbles, spinners, markdown renderers. But a harness is not a chat. A harness needs a status row that proves the agent is alive and tells you what this turn costs. An approval prompt that gates a real action on your real machine, with the safe option first. A home screen that says which model is pointed at which screen. Nobody ships those, so every team on that chart has built them from scratch, and several built them wrong in the same off-by-one-cell ways. A copy-paste culture is forming around the problem, in the mold of shadcn/ui: registries like termcn, TermUI, and InkUI distribute terminal component source you own and edit. Vercel's @ai-sdk/tui attacks the same space from the SDK side. --- I got tired of rebuilding the harness parts too, so I decided to open source the parts I use. agentparts is a small library of exactly these components, built on OpenTUI: the turn status row, the approval prompt, the shell layout, the prompt box. All the width arithmetic lives in a renderer-free core that runs on plain Node. Feel free to use for your harnesses! !Screenshot of AgentParts, terminal components for agent harnesses: model and policy info, a list of recent agent sessions, and a prompt box with key hints. > >> arthurkatcher/agentparts --- So you want to build one Start with an honest question: does this need to be full-screen at all? Aider and Goose are proof that a well-formatted REPL can carry a serious product. If your interface is a conversation with occasional structure, print good output and stop. Reach for a TUI framework when you need layout: panes, status rows, things that update in place. !Decision tree titled Choosing a TUI stack: if it doesn't need the whole screen, a REPL with good output; if it does, a full-screen framework by language: Ink or OpenTUI for TypeScript, ratatui for Rust, Bubble Tea v2 for Go, Textual for Python. If you do need one, the choice is mostly made by the language you already live in: | You write | Reach for | Why | |---|---|---| | TypeScript | Ink, or OpenTUI | Ink is mature; OpenTUI adds speed, mouse, images | | Rust | ratatui + crossterm | the default; start from ratatui/templates | | Go | Bubble Tea v2 + Bubbles | best component library, Lip Gloss for styling | | Python | Textual | CSS-like styling, rich widget gallery | The beautiful part is not the framework. It is discipline about the medium: Design a palette, then design its absence. Pick a handful of truecolor values on purpose, and decide what the interface looks like with color stripped away: plainly unstyled, never broken. Honor NO_COLOR, the environment variable users set when they want none. Get width right. Test with CJK text and emoji, and snapshot your layout at 40, 80, and 132 columns, because every TUI bug is an off-by-one-cell bug. Never flicker. Wrap each frame in synchronized output, the escape pair that tells the terminal to paint it in one go, and diff before you draw. Shed, don't squish. When the window gets narrow, drop the least important element entirely instead of truncating everything a little. And steal the parts that already exist: the spinners, the status rows, the prompts. Bubbles has them in Go, the registries have them in TypeScript. The chrome is the least differentiated thing you will build, which is exactly why you should not build it twice. Step back far enough and the cycle stops looking like a cycle. We did not return to the terminal we left. We brought everything back with us: React reconcilers running flexbox, CSS-like styling, component registries, GPU rendering, twenty years of interface craft applied to a character grid. The real trade was never terminal versus GUI. It is fidelity versus leverage, how rich an interface can be against how many places it can reach, and right now leverage is winning. Good design finally reached the terminal, one escape sequence at a time. --- Originally published on arthurkatcher.com.