Skip to content

Debugging & AI agents

A ztui UI is never a black box. The widget DOM and the rendered frame serialize to plain text, HTML, and JSON, the app runs headless with no TTY, and a REST inspector exposes the live screen over HTTP. That’s what makes ztui practical to test in CI and to operate with an LLM agent — the thing a native byte-stream backend can’t give you (see Why ztui).

The composed cell grid renders to text or styled HTML — no terminal required:

import { App, MockDriver, renderBufferToText, renderBufferToHTML } from "@huyz0/ztui";
import { render } from "@huyz0/ztui/react";
const app = new App(new MockDriver(40, 10)); // headless, fixed-size grid
render(<MyUI />, app.activeScreen);
app.run();
renderBufferToText(app.buffer); // the screen as plain text
renderBufferToHTML(app.buffer); // the screen as styled HTML (for snapshots)

MockDriver is a no-TTY backend built for this: it captures output and lets you inject input, so the same widget tree you ship runs in a test or a script.

Because the frame is text, assertions are ordinary string checks — no screenshots, no flaky pixel diffs:

import { App, MockDriver, renderBufferToText } from "@huyz0/ztui";
import { render } from "@huyz0/ztui/react";
test("counter increments", async () => {
const driver = new MockDriver(40, 5);
const app = new App(driver);
render(<Counter />, app.activeScreen);
app.run();
await new Promise((r) => setTimeout(r, 15)); // let React commit + render
driver.simulateKey({ key: "enter", name: "enter", ctrl: false, meta: false, shift: false });
await new Promise((r) => setTimeout(r, 15));
expect(renderBufferToText(app.buffer)).toContain("Count: 1");
app.stop();
});

MockDriver exposes simulateKey / simulateMouse / simulateResize to drive the app, and writtenData to assert on raw output.

For a running app, startInspector(app) serves the live screen over HTTP — so a human, a script, or an agent can watch and drive it from outside the process:

import { startInspector } from "@huyz0/ztui";
const inspector = startInspector(app, 8000); // GET endpoints + POST /input
EndpointReturns
GET /screenshotthe current screen as plain text
GET /renderthe current screen as styled HTML
GET /domthe widget tree as JSON (ids, classes, regions)
GET /treethe widget tree as an indented text outline
GET /statea high-level app snapshot (focus, size, capabilities)
GET /log?lines=Nthe last N log lines
POST /inputinject a key or mouse event
Terminal window
curl localhost:8000/screenshot # see the screen
curl -XPOST localhost:8000/input -d '{"type":"key","key":"enter"}' # press a key
curl -XPOST localhost:8000/input -d '{"type":"mouse","x":4,"y":2,"action":"press"}'

These two facts — read the screen as text, inject input over HTTP — close the loop for an AI agent with no terminal at all:

  1. GET /screenshot → the agent reads the current UI as text.
  2. The agent decides what to do.
  3. POST /input → it presses a key or clicks.
  4. Repeat.

The same loop works offline against MockDriver (renderBufferToText + simulateKey) for deterministic, fully-scripted runs in CI. An agent can build a feature, drive its own UI, read back the rendered result, and assert it’s correct — without a human relaying screenshots.

The browser/canvas backend is inspectable too. bun run web:debug renders a UI in headless Chromium, saves a screenshot, and prints a pixel-accurate report (row gaps, overflow, font-loaded, cell width) — the way to verify the web backend in CI without a person at a browser.

Performance: benchmarks & regression guards

Section titled “Performance: benchmarks & regression guards”

ztui re-renders the whole widget tree to a cell buffer and diffs it to ANSI every frame, so a small algorithmic regression in the render/layout core can silently hurt interactivity. Two commands cover the hot paths (buffer/diff, ANSI serialization, text measurement & wrapping, layout, selection, markdown, CSS resolution, and the end-to-end frame):

  • bun run perfratio-guard tests (src/**/*.perf.ts). Each hot path is timed against a fixed calibration workload measured in the same process; the assertion is on the ratio, so it’s machine-independent and only trips on a real (order-of-magnitude) regression. Runs in CI. Some guards also assert deterministic invariants — e.g. an unchanged frame must diff to the empty string — which catch regressions with zero timing flake.
  • bun run benchvitest bench() tracking (src/**/*.bench.ts). Prints ops/sec for eyeballing gradual drift; not asserted.

Both use the shared harness in src/test/bench/perf-harness.ts and run on a dedicated config (vitest.config.perf.ts), kept out of the default coverage gate so commits stay fast. Budgets are committed constants set to ≈3× a healthy run; if you make a deliberate change that moves a baseline, retune the budget in that .perf.ts file (the failure message prints the observed ratio).

Every other guarantee in ztui flows from this: a custom widget is correct if its serialized output is correct; a regression shows up as a text diff; an agent can operate an interface it can’t see. The UI is data you can query, not pixels you have to look at — which is exactly what testing and AI-assisted development need.