# 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 ( │ │ │ │
│ │ │ │
setCount(c => c + 1)}>+
│ │ │ │
│ │ │ │ ) │ │ │ │ } │ │ │ └─────────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ @gpuix/react (packages/react) │ │ │ │ │ │ │ │ - React Reconciler (react-reconciler) │ │ │ │ - Builds element tree from React components │ │ │ │ - Serializes to JSON ElementDesc │ │ │ │ - Manages event handler registry │ │ │ └─────────────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ @gpuix/solid (packages/solid) │ │ │ │ - Solid 1 universal renderer and reactive components │ │ │ │ - Bun preload and production build plugin │ │ │ └─────────────────────────────────────────────────────────┘ │ │ ↓ JSON │ └─────────────────────────────────────────────────────────────────┘ ↓ napi-rs FFI ┌─────────────────────────────────────────────────────────────────┐ │ Rust / Native │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ @gpuix/native (packages/native) │ │ │ │ │ │ │ │ - GpuixRenderer: receives JSON, triggers re-render │ │ │ │ - build_element(): ElementDesc → GPUI elements │ │ │ │ - apply_styles(): StyleDesc → GPUI style methods │ │ │ │ - Event handlers → ThreadsafeFunction callbacks to JS │ │ │ └─────────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ GPUI (from zed) │ │ │ │ │ │ │ │ - Immediate-mode UI framework │ │ │ │ - Flexbox layout via Taffy │ │ │ │ - GPU rendering via Metal (macOS) / Vulkan (Linux) │ │ │ │ - Native window management │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ## Key Insight: Immediate Mode Alignment GPUI is **immediate-mode** - it rebuilds the entire UI tree every frame. This actually aligns perfectly with React's model: | Traditional DOM Renderer | GPUIX | |--------------------------|-------| | `appendChild(node)` | Rebuild tree each render | | `node.style.color = x` | Send full tree description | | Mutation-based | Description-based | We don't fight GPUI's architecture - we embrace it by sending a complete element description on every React render. ## Shared JavaScript runtime Framework-neutral TypeScript belongs in `@gpuix/native`, not in an adapter. `@gpuix/native/host` owns host contracts, mutation batching, renderer-local IDs, root ownership, event routing, observers, and text search. `@gpuix/native/testing` and `@gpuix/native/automation` own the shared native test wrapper and automation client. These subpaths must not load the `.node` addon at import time, except `testing`, which constructs the native test class. React and Solid keep only framework scheduling, context, JSX integration, and components. React preserves its public testing and automation subpaths as thin adapters or re-exports. Do not copy shared runtime code back into an adapter. `@gpuix/native/runtime` owns the desktop pump. On macOS it wakes on GPUI's display link, with a 100 ms fallback when no wake arrives. `tick()` must stay non-blocking so Node.js and Bun I/O can run. Explicit `frameMs` uses timer polling. The native package is ESM-first. Generate `index.js` with `napi build --platform --esm --js index.js`. `index.cjs` exists only for the documented Hermes CJS runtime. Keep both loaders beside the `.node` file. Solid applications use `jsx: "preserve"`, `jsxImportSource: "@gpuix/solid"`, and `@gpuix/solid/preload` in `bunfig.toml`. The preload runs the Solid universal compiler and selects Solid's client runtime. Do not add Vite or a wrapper CLI. ## Package Structure ``` gpuix/ ├── cli/ # `gpuix new` project scaffolder │ ├── src/cli.ts # Goke CLI and example-app extraction │ └── package.json │ ├── packages/ │ ├── native/ # Rust napi-rs bindings │ │ ├── js/ # Shared host runtime, testing, automation │ │ ├── src/ │ │ │ ├── lib.rs # Module exports │ │ │ ├── renderer.rs # GpuixRenderer, GpuixView, build_element() │ │ │ ├── element_tree.rs # ElementDesc, EventPayload types │ │ │ ├── style.rs # StyleDesc, color parsing │ │ │ ├── theme.rs # Comet palette, oklch helpers, JS overrides │ │ │ ├── text/ # Selection: state, paint registry, TextRuns │ │ │ ├── syntax/ # Syntect highlighting + bounded cache │ │ │ ├── markdown/ # pulldown-cmark parser + gpui renderer │ │ │ ├── diff/ # Unified-patch parser + row flattening │ │ │ └── custom_elements/# input, img, svg, anchored, code, diff, markdown │ │ ├── examples/ │ │ │ └── hello.rs # Pure GPUI test (no JS) │ │ ├── Cargo.toml │ │ └── build.rs │ │ │ ├── gpui-terminal/ # GPUI platform for kitty-graphics terminals │ │ # (`terminal: true`), standalone Rust crate │ │ │ ├── react/ # React reconciler │ ├── src/ │ │ ├── index.ts # Public exports │ │ ├── reconciler/ │ │ │ ├── host-config.ts # React reconciler implementation │ │ │ ├── reconciler.ts # ReactReconciler instance │ │ │ └── renderer.ts # createRoot(), event bridge │ │ ├── hooks/ │ │ │ ├── use-gpuix.ts # Context access │ │ │ └── use-window-size.ts │ │ └── types/ │ │ └── host.ts # TypeScript types │ └── package.json │ └── solid/ # Solid 1 universal renderer │ ├── src/ # Host tree, roots, primitives, controls │ └── package.json │ ├── examples/ │ ├── package.json # Workspace package for examples │ └── counter.tsx # Example React app │ └── AGENTS.md # This file ``` ## Text rendering: one funnel, no exceptions Every string GPUIX paints goes through `crate::text`: - `selectable_text(..)` for content — registers into the per-frame selection registry and installs the window mouse and key listeners - `chrome_text(..)` for line numbers, language tags and file headers — painted and logged for tests, but never part of a selection **Never call `div().child(some_string)` in a new element.** Doing so makes the text invisible to selection AND to `getPaintedText()`, so it cannot be tested except by screenshot. The registry is rebuilt during **paint**, not during build, because paint order is the only place document order is guaranteed: a `list()` decides at paint time which rows exist. `selection_frame_reset()` must stay the first child of the root, or stale entries from the previous frame leak into the next drag. ## Text is grouped, because React splits one line into many nodes `shouldSetTextContent` is false, so `Hello {name}!` becomes **three** host text nodes and three `TextLayout`s. Anything that reasons about a line, copy and `highlight` both, must merge them first. The rule is structural: **adjacent** primitive text children of the same parent form one group. Never derive it from `display`; `apply_styles` only knows `flex` and `grid`, and every text node already sits inside a `div`. Copy and search must agree, so both call `search::group_id`. Using `element.parent` for one and adjacency for the other is a silent divergence: a non-text sibling between two leaves ends the run for search but not for copy. `None` means a run that never merges, which is every native element line. ## `highlight` resolves per subtree, with two caches `crate::text::search` deliberately builds **no joined document** for a query. It matches per group, so a 5k-row chat is 5k small strings rather than one megabyte string rebuilt on every keystroke. A joined document exists only when a spec supplies explicit `ranges`, because only then does someone index into it. `HighlightCacheEntry` has two levels and they must stay separate: - the `GroupList` is keyed by **`search_revision`** - the `MatchSet` is additionally keyed by `HighlightSet::matcher_hash()`, which **excludes** `activeIndex` and the colours; a cursor move swaps only the spec `search_revision` exists because `highlight` is itself a custom prop, so `subtree_revision` moves on every keystroke. Key the group list on that and a find-bar keystroke re-walks and re-folds the whole subtree. **A timing budget does not catch this.** On the 1000-turn chat the broken version is 2.7ms against 1.9ms, because most of that text lives in native element props rather than retained ``. `highlight_cache_tests` in `renderer.rs` compares `Arc` identity instead, and fails outright. Native elements (``, ``, ``) 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** (``, ``) and **``** use `crate::automation::track_own_bounds(el, id)`, which is gpui's `on_painted`. Wrapping a leaf in a div instead would move the layout box: the wrapper becomes the flex item, and the image loses intrinsic sizing and corner clipping. `` uses it because only gpui knows where the overlay landed after snapping. Both record during **paint**, and `bounds_frame_reset` clears the registry during paint too. Never move any of them to prepaint: `gpui::list()` prepaints a speculative row range, then rolls the window back through `Window::transact` and prepaints a different one, so a prepaint-recorded box can belong to a row that never reached the screen. ## A macOS menu item owns its shortcut, so the window never sees it `crate::app_menu` installs the App and Window menus during `init_macos`. GPUI does not do this on its own: `NSApp.mainMenu` stays nil, macOS paints an empty menu bar, and `⌘Q`, `⌘H`, `⌘M` and `⌘W` do not exist, because AppKit only provides them through menu items. **Never add an Edit menu carrying `⌘C` / `⌘V` / `⌘X` / `⌘A`.** AppKit consumes a key equivalent before the window sees the key event, so those items would take the keystroke away from the selection listener in `text::paint` and from the per-focus clipboard handling in `custom_elements::input`. An Edit menu needs those handlers moved into GPUI actions first. `gpui::App::set_menus` reads each shortcut out of the keymap, so bind the keys **before** you call it. Window-level items (`MinimizeWindow`, `ZoomWindow`, `CloseWindow`) go through `with_window_menu_actions` on the root element in `GpuixView::render`, because a `Window` exists nowhere else; app-level ones (`Quit`, `Hide`, `HideOthers`, `ShowAll`) are `cx.on_action` globals. Two things real AppKit decides for you. The **title of the application menu is the executable name**, not the `Menu` name you pass, so `bun app.tsx` shows `bun`; only a `.app` bundle changes it. And the menu named `Window` is handed to `setWindowsMenu:`, which prepends AppKit's own tiling items, `Enter Full Screen` included. Do not add that item yourself. Verify with the accessibility tree, not a screenshot. The system menu bar is outside the window, so GPUIX automation cannot see it. ```bash osascript -e 'tell application "System Events" to tell (first process whose unix id is PID) \ to get name of every menu bar item of menu bar 1' ``` ## Browser keyboard input goes through GPUI's hidden element A GPUI web app has two event surfaces. The `` takes pointer events. A hidden `[data-gpui-input]` element appended to `` takes **every keyboard and IME event**: `gpui_web`'s `listen_input` attaches `keydown` / `keyup` there, not to the window or the canvas. Dispatching a synthetic `KeyboardEvent` at that element is therefore the only way automation can type into a browser app. **Match it by attribute only** (`IME_MIRROR_SELECTOR` in `automation/client.ts`). It used to be an ``; [zed-industries/zed#63201](https://github.com/zed-industries/zed/pull/63201) replaced it with a `