# AGENTS.md - GPUIX Codebase Guide **Read [README.md](./README.md) first** to understand what GPUIX is, the architecture, mutation API, event flow, supported elements/events/styles, and the test renderer. Unless you are **remorses** or **monotykamary**, do not open a pull request. Open an issue. See [External contributors](#external-contributors). ## README is the public API contract Document every user-facing feature, element, prop, event, renderer option, public method, and behavior change in `README.md` in the same change. A changeset does not replace API documentation. ## GPUIX is a thin layer on GPUI **Read the GPUI docs and the GPUI source before you write native code.** `zed/crates/gpui` is checked out in this repository. `gpui::ListState`, `gpui::div`, `gpui::Window` and the rest are the real API; GPUIX only translates a React tree into calls on them. Do not invent behaviour on top of GPUI. If a GPUIX element needs something GPUI does not do, the order is: 1. Find the GPUI API that already does it. Search `zed/crates/gpui` for the symbol 2. Search `zed-industries/zed` issues and PRs. Someone may have shipped it already 3. Fix it in the `remorses/zed` fork as a normal GPUI change, and bump the submodule 4. Only then, add GPUIX code **Never paper over GPUI in `packages/native`.** A workaround that re-applies state after GPUI computed it, patches a value GPUI owns, or reaches around a GPUI invariant will break on the next submodule bump and is very hard to debug. When such a change is unavoidable, it must state in a comment what GPUI does, why that is not what GPUIX needs, and which GPUI call makes it safe. Prefer the smallest translation. Fewer moving parts is more important than matching any other framework's behaviour. ## No hooks unless the value is reactive Do not add a hook to `@gpuix/react` or a primitive to `@gpuix/solid` unless the value changes over time and the component must re-render when it does (`useWindowSize`, `useWindowInsets`). A value that is fixed once the window opens is a plain exported function, like `isTerminal()`. Apps and examples follow the same rule: no `useState`, `useMemo`, or custom hook for a value that a plain call or constant gives. ## Built-in components follow Base UI Headless controls in `@gpuix/react` and `@gpuix/solid` (`select`, `combobox`, `tooltip`, and any new primitive) should match [Base UI](https://base-ui.com/react/components/select) first: same split between Root data and children. For Select, `items` on Root is optional. It is only a label lookup for `SelectValue` while the popup is closed. Keyboard nav and clicks read the mounted `SelectItem` children. Do not walk `child.type`. Do not require `items` for the menu to work. ## Project Goal GPUIX enables building **native GPU-accelerated desktop applications** using **React or Solid and TypeScript**, powered by [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui) (Zed's rendering framework). Instead of Electron/web rendering, your React components render directly to the GPU via Metal/Vulkan. ## Mouse capture is armed by the press A `div` with `onMouseDown`, `onMouseMove`, and `onMouseUp` keeps move and up after the pointer leaves the hitbox. GPUIX arms that automatically when the same node listens for down and move, using GPUI's window-level mouse listeners. Put all three on the element the user grabs. Capture is armed by the **press**, so an overlay mounted during that press never arms it, and a release past the window edge is lost. `examples/timeline.tsx` drags clips, trims edges, scrubs, and marquee-selects with no overlay at all. ``` React (TypeScript) → napi-rs → GPUI (Rust) → GPU Your code Bridge Native render Metal/Vulkan ``` ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ JavaScript / TypeScript │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Your React App │ │ │ │ │ │ │ │ function App() { │ │ │ │ const [count, setCount] = useState(0) │ │ │ │ return ( │ │ │ │
`, ``, ``) generate text inside
`render()`, so the build-time resolver cannot see it. They match the exact string
they are about to paint, through `washes_for_native_run`. Do **not** add a second
traversal that re-derives their text and `sub` values; markdown assigns `sub`
with a render-time counter and the two would drift.
**Ordinals are allocated during paint, inside `search::wash`, for retained and
native matches alike.** `activeIndex` means the nth match in the document, and
only paint knows that order: retained matches are located before the frame,
native text exists only during it, and a subtree can interleave them. Numbering
each kind separately made `activeIndex: 0` mark the `` match even when a
`` block came first.
Two things that look redundant there are not. `MatchId::Retained` carries the
build-time index so a match split over several interpolated runs takes exactly
one ordinal. The `assigned` memo makes a row gpui paints twice keep its numbers
rather than advance the cursor again.
Paint only sees what is mounted, so a **virtualized subtree must say where it
starts**. `matchIndexOffset` is the number of **matches** above the window, not
a row index, and the sequence begins there. Without it `activeIndex` silently
means "the nth visible match" and a find cursor lands on the wrong row.
`` already takes `windowStart` and `itemCount` from the app for the
same reason. It is excluded from `matcher_hash`, like `activeIndex`, so scrolling
never rescans text. A malformed value **rejects the whole spec**: a bad offset
only shows up as a cursor on the wrong row, so silence is worse than nothing.
`useTextSearch` takes both numbers as one `matches: { total, indexOffset }`
object. They are never individually correct: native counts and numbers the same
window, so an app that overrides one must override the other.
`onHighlight` is queued during build and flushed **after** the root build
returns, keyed on `MatchSet::identity()` rather than the count. Emitting inline
lets a `setState` in the handler re-enter the build; keying on the count alone
misses a query swap that finds the same number of hits, and including the
colours makes a cursor move look like a new result. `reported` is written only
when an event is really queued, or adding `onHighlight` after the first render
would report nothing forever.
A `` row is built from `cx.processor` **after** the root render
returned, so `build_virtual_child` re-resolves the declaration against the tree
as it is then. On Windows and Linux the Node thread can commit new text in
between, and a captured range would paint over the wrong glyphs.
Content is searchable even under `userSelect: "none"`, because a browser still
finds it. `chrome_text` cannot paint a wash, so it is only for real chrome:
gutters, language tags, diff file headers.
## Layout numbers live in `Theme::metrics`, not in Rust constants
Row heights, gutter widths, paddings, text sizes and the heading scale are all
fields on `crate::theme::Metrics`, reachable from JS as `theme.metrics`.
**Do not add a new `const` for anything that decides layout.** Put it on
`Metrics`, give it a default, add it to `MetricsOverride`, `hash_into`, and the
`GpuixMetrics` TypeScript interface. The whole point is that a design tweak is a
React re-render, not a native rebuild.
Two things stay constant, because they are paint geometry and cannot move a
glyph: the table hairline, and the inline-code wash overhang.
`` derives its virtualized height model from the metrics without
measuring, so `DiffElement` re-runs `reset_with_uniform_height` whenever
`Metrics::hash_into` changes. Forget that and the scrollbar drifts from the
content.
## Iterating on the Rust side
There is no hot reload and there cannot be: `require()` of a `.node` calls
`process.dlopen`, Node has no unload, and the event loop, GPU device, window and
selection registry all live in thread-locals of the loaded library.
Use `bun run dev` (see `scripts/dev.ts`). It watches `packages/native/src`,
rebuilds, and re-renders the screenshot tests. **A Rust edit reaches fresh PNGs
in about 4 seconds.** Prefer screenshot mode over `--app`: PNGs in
`packages/react/screenshots/` can be read by an agent, a live window cannot.
**Never ship or start the app on a debug native build.** `bun run build:debug`
and `cargo build` without `--release` produce an unoptimized `.node`. GPUI
paint is then many times slower, and that looks like an app bug. Always use
`bun run build` in `packages/native` (release). Use `build:debug` only when
the user asks, or when a debug-only tool (lldb, sanitizers) cannot run on
release. After any debug build, rebuild release before starting `chat.tsx`
or judging frame time.
## Two Bun modes, only one of them refreshes React
`bun --hot chat.tsx` is the **runtime**. It re-evaluates the module graph in the
same process, so `render()` finds its window on `globalThis.__gpuixRenderHost`
and remounts. There is no bundler, so there is no Fast Refresh transform and no
`import.meta.hot`. Every save loses `useState`. Tracked upstream as
[oven-sh/bun#40179](https://github.com/oven-sh/bun/issues/40179).
`bun scripts/web.ts` is the **bundler dev server**. Bun applies the Fast Refresh
transform and its HMR runtime calls `refreshRuntime.injectIntoGlobalHook(window)`,
which is the only thing our reconciler needs: `injectIntoDevTools()` in
`reconciler.ts` hands the hook `scheduleRefresh` and `setRefreshHandler`, and
`react-refresh` drives updates through them.
Delete that call and you get **no error and no page reload**. Bun still accepts
the update and still calls `performReactRefresh()`, which iterates zero mounted
roots and schedules nothing. The bundle changes and the painted UI stays stale.
`fast-refresh.test.tsx` is the only thing that catches it.
Do **not** assert on the return value of `injectIntoDevTools()`. It ends in
`hook.checkDCE ? true : false`, and `react-refresh` installs no `checkDCE`, so a
working injection still returns `false`. `fast-refresh.test.tsx` asserts the
behaviour instead: `_getMountedRootCount()`, then a component swap that keeps
state.
**Never add `import.meta.hot.accept("./app", ...)` to a browser entry.** Bun runs
an importer's dependency-accept callback even when the imported module already
self-accepted for Fast Refresh. The callback then remounts on top of a
successful refresh and wipes every hook. This looks exactly like Fast Refresh
being broken, and it is not.
**Never run `bun run clean` in `packages/react` while the web dev server is up.**
That folder is inside the dev server's module graph, so removing it under a
running server permanently corrupts the registry: every page load then fails
with `Failed to load bundled module 'packages/react/dist/index.js'`, even after
a hard reload, and only a server restart clears it. This is why `build` is plain
`tsc` and the wipe lives in a separate `clean` script. Clean first, then start
the server.
## A new element needs a host-derived GPUI id, or it has no state
`.id(..)` is not decoration. gpui keys `InteractiveElementState` off the
`GlobalElementId`, so an element without one silently loses **hover, active,
pointer capture, implicit scroll, its accessibility node, and any element state
gpui itself keeps**. `
` had no id, which is why an animated GIF never left
frame zero: `ImgState` holds the frame index.
`` and `` use `gpui::ElementId::Integer(host_id)`. Host ids are
already unique per renderer, and a formatted name cost a `SharedString`
allocation on every node on every frame. Custom elements use
`ElementId::Name("__gpuix__")`; that is a different enum variant,
so the two namespaces cannot collide.
**Never call `.id()` in this crate.** `impl From for ElementId`
makes the idiomatic gpui row id an `Integer`, which is the same namespace as a
host id. Every per-row id here is a formatted name for that reason:
`__gpuix_diff_line_{ix}`, `__gpuix_md_table_{id}_{sub}`, and the rest.
**Never call `apply_styles` on a stateful root. Call `apply_interactive_styles`.**
`StyleDesc` carries `hover` and `active` for every element type, so a builder
that applies only the base styles type-checks the prop, serializes it, and drops
it. `custom_surface` in `custom_elements/mod.rs` does this for you.
## Bounds: a container uses a tracker, a leaf uses `on_painted`
`getByTestId(..).click()` needs a recorded box. Two mechanisms, both required:
- **Containers** (``, ``, ``, ``, ``, ``)
add `crate::automation::bounds_tracker(id, selection_start)` as a child. It is
`absolute().size_full()`, so the parent must be positioned. Pass
`Some(selectable)` when the element also owns a selection-start region; the
editor uses `Some(false)` so a drag moves the caret instead of starting a
document selection. `custom_surface` attaches it.
- **Leaves** (`
`, `