`render(<App />, { terminal: true })` or `GPUIX_TERMINAL=1` runs the same
GpuixView on gpui_terminal, a GPUI platform in the Zed fork that paints with
the kitty graphics protocol. No native window opens.
```
macOS Linux
renderer.init ─► init_macos renderer.init ─► init_threaded
EmbeddedPlatform::Terminal gpuix-ui thread
TerminalPlatform::new_embedded() gpui_terminal::application().run()
tick() ─► pump_events()
```
- renderer.rs: `terminal_requested()` reads the option and the env var;
macOS swaps MacPlatform for TerminalPlatform inside the embedded path, so
tick() and the frame wake hook are unchanged. No per-method branches
- input.rs: `init_terminal` unbinds ctrl-c in text field contexts so it
reaches the platform and quits
- `isTerminal()` exported from @gpuix/native, @gpuix/react and @gpuix/solid
as a plain function (not a hook); browser.mjs returns false
- examples/chat.tsx, mail.tsx drop the traffic-light gap in a terminal
- AGENTS.md: terminal mode swaps the platform; no hooks for non-reactive values
- README: "Run in a terminal" section and the `terminal` option row. The same
README hunk also carries a note on `windowBackground: "blurred"` tinting
- docs/terminal-renderer.md: architecture, traps, research notes
- zed submodule: gpuix-terminal commits (gpui_terminal crate, embedded mode,
SIGWINCH-only forwarding)
- changeset (minor for native, react, solid)
Discord: https://discord.com/channels/1422625037164351591/1562055543466496043
Session: ses_ea7105a47ffeNLpB02sEyo5t9g
Reveal disktree selections through Finder, Explorer, or xdg-open instead of always invoking macOS open. Keep launcher errors in the selection panel and scope them to the selected path. Preserve Explorer selection arguments for paths with spaces. Normalize platform button labels in the existing scan/navigation snapshot.
Use the existing titlebarTransparent option to retain native Windows dragging and minimize/maximize/close controls in chat and disktree. Leave other platforms unchanged. Document both behaviors and add separate changesets for issues 75 and 77.
Validation: all package, Rust, and performance suites pass on macOS. A real missing-launcher probe leaves the selection panel responsive. Windows and Linux runtime behavior remains unverified locally.
Session: ses_ed48f90d4ffeqym03XRjqsA5xw
Discord: https://discord.com/channels/1422625037164351591/1558853977012174980
Each render() call wrapped the tree in a Provider whose value was a new
{ renderer } object, so every call was a context change. In the chat example
that re-rendered ChatApp, whose inline onRetry closure then busted the memo on
Transcript: 305 SafeMdxContent renders and about 9 ms per find-bar keystroke.
The example also keeps onRetry stable now, so typing in the composer no longer
re-renders every row.
`bunx disktree [dir]` opens the treemap on a folder, the current directory,
or `/`. Published as disktree@0.1.0.
- moved from examples/ to a `disktree` workspace package: src/app.tsx
(store + view), src/cli.tsx (bin), src/core.ts, src/scan-worker.ts
- scan runs on a pool of worker threads with sync lstat, like the Rust
original on rayon; the frontier is handed back after 400 dirs so deep
trees spread across workers. Full disk here: 24.5M files in 150 s, no
stall
- skips iCloud dataless folders (SF_DATALESS via bun:ffi lstat), like the
Rust scanner; listing them made macOS download them and stalled the scan
- keeps the 12 largest files per directory, folds the rest into one
"N smaller files" tile
- store lives on globalThis so `bun --hot` remounts instead of starting a
second full scan
- scan progress bar uses used disk space as the total when scanning a
volume root
- lighter pastel tiles at 90% opacity
Session: ses_f1cc78bbbffeiqA1qhn3vB0pzN
- controls are near-opaque white pills with a soft shadow; selected and
pressed states are gray, like Finder's view switcher
- window tint is lighter gray and more transparent, so the desktop shows
through; softer panel shadow
- scanning `/` on macOS skips /System/Volumes/Data: the data volume is
also firmlinked into `/`, so walking both counted it twice
Session: ses_f1cc78bbbffeiqA1qhn3vB0pzN
`bun --hot examples/disktree.tsx [dir]` scans the given directory, or the
current one, and draws what fills it.
- disktree-core.ts: lstat scan (disk usage = st_blocks x 512, hardlinks
once, one volume), name-based kinds and reclaim reasons, squarified
treemap with a name band per open folder, "Worth a look" ranking
- disktree.tsx: frosted window, translucent tiles in the palette hues,
hatched reclaimable space, black or white labels by WCAG contrast of the
composited tile colour, translucent white side panel, Size/Files/Age,
hidden and apparent-size toggles, depth stepper, back button
- one click opens a folder; back button, backspace and Esc go up
- disktree.test.tsx: real scan of a fixture folder, then clicks through
the GPU test renderer
Session: ses_f1cc78bbbffeiqA1qhn3vB0pzN
GPUI stores color images as BGRA (RenderImage, Metal and DirectX atlases).
setImagePixels only took RGBA, so every upload swapped bytes 0 and 2 of
every pixel on the UI thread. BGRA sources (ffmpeg -pix_fmt bgra,
VideoToolbox, node-canvas toBuffer('raw')) now skip that pass.
- native: PixelFormat enum in img.rs; napi, test renderer, UiCommand and
wasm bindings take an optional format. Unknown formats throw
- host types: ImagePixelFormat, ImagePixelsOptions
- react/solid: setImagePixels(w, h, pixels, { format })
- waveform example writes BGRA by hand
- README: format docs, node-canvas raw is BGRA premultiplied (opaque only)
- test: rgba and bgra of the same color paint byte-identical screenshots,
misread bytes swap red/blue, unknown format throws
Session: ses_f230bc6fdffe5CqhxBXqCeacz9
- examples/chat.tsx and examples/solid/chat.tsx: every div + onClick
control (IconButton, SidebarAction, ConversationRow, thread filter,
WorkedFor, ModeToggle, GhostButton, search hits, cycle-overlay) is a
Button, so the chat is fully keyboard reachable. OverlayCard is a Dialog:
Escape, backdrop press, Tab trap, and focus in (search input via
initialFocus) and back out to the opener. Overlays stay mounted with
open={...} instead of conditional rendering.
- example-app/app.tsx: IconButton, SidebarRow, Checkbox, Add are Buttons;
Add is disabled while the draft is empty.
- Select.Content and Dialog.Popup set focusVisible: {} so the default ring
does not outline the whole menu or card; they are focused only to take
keys.
- @gpuix/solid exports the HostElement type for refs.
- chat.test.tsx: Escape closes search and focus returns to its button.
Solid chat port done by a subagent; verified with chat.automation.ts
(6/6) and a throwaway test of open, Escape, close, and focus return.
Session: ses_f2717eddcffeXQkI5JXN8eAnFD
Drop the previous GPU image when src or a live upload replaces it, and when the img node is pruned. Without drop_image, every setImagePixels frame leaked a RenderImage.
scrollIntoView now uses the virtual-list logical index, not the windowed child index. A windowStart of 50 plus child 5 now scrolls to item 55.
Attach setImage, setImagePixels, and scrollIntoView in getPublicInstance instead of createInstance, so concurrent React can abandon a host node before the methods exist. Pass the original buffer through instead of copying it with Buffer.from.
The waveform test compares two live phases. Encoded setImage and windowed scrollIntoView have screenshot and list tests.
Session: ses_f3c07efdbffeHwbaQhsE7fIz5z
Port the chat example to @gpuix/solid under examples/solid with jsx
preserve, the Solid bunfig preload, and a matching automation driver.
examples/package.json now depends on @gpuix/solid and solid-js and
adds chat-solid. bun.lock follows that.
mem-mount.tsx (React) and examples/solid/mem-mount.tsx print mount
time plus RSS/heap for the same ChatApp so the two adapters can be
compared.
Session: ses_f3c65bfd5ffegJLklT8domWQ7B
`<img>` refs can push image bytes without a data URL or JSON mutation.
`setImage` takes encoded PNG, JPEG, WebP, GIF, SVG, BMP, TIFF, ICO, or
Netpbm. `setImagePixels` takes packed RGBA. Prefer pixels for a live
waveform. There is no density argument. Upload a 2x bitmap into a 1x
layout box on retina.
Every host ref now has `scrollIntoView()`, which scrolls the nearest
overflow parent or `<virtual-list>` until that node is visible.
The waveform example paints a generated 2x RGBA buffer through
`setImagePixels`.
Session: ses_f3c07efdbffeHwbaQhsE7fIz5z
The count is two words, so without nowrap it wraps at the space when the
title takes the remaining width. whiteSpace nowrap plus flexShrink 0
keeps 1,000 messages together. The title still shrinks.
Session: ses_f5601bbdaffevYnfCmDhYOddPC
GPUI uses a flat painted hit list and does not bubble clicks. A custom row
with its own background can therefore cover SelectItem or ComboboxItem and
prevent selection, hover state, and popup close behavior.
Add asChild to both item primitives through the existing renderSlot path.
The custom row becomes the host item itself, so it owns one native hit target,
one event id, the item ref, composed handlers, state styles, and disabled
selection behavior.
Migrate every chat picker to asChild. MenuRow now forwards its ref and host
props, while keeping its own row padding. Add end-to-end tests for filled
Select and Combobox rows and a chat regression assertion for menu row height.
Document when to use asChild, including the ref and host-prop forwarding
contract. Add a React patch changeset with the new public API.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
Select rows stole the click. GPUI has no bubbling, so a filled MenuRow
sat on top of SelectItem and the pick never ran. The fill now lives on
the item. Inner icons and labels set pointerEvents none.
Search stayed at 420px and closed on an outside press. The dim layer
used to take the hit, so onMouseDownOutside never fired. The listener
is on the card. Opening search autoFocuses the filter field.
The composer stays mounted across threads, so autoFocus only ran once.
New Task, a thread switch, and history now call focusElement.
The demo reply no longer bolds the last word, so markdown cannot put a
period on the next line.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
Each sidebar row now owns its own transcript. Clicking a thread paints
that conversation. New Task prepends a blank Today thread and clears
the composer. Sends append the user turn, then a demo assistant reply
that says this is the GPUIX chat demo. Retry rebuilds that last reply.
History arrows walk a stack of thread ids. Search filters titles.
Settings shows a short note and can cycle the frame overlay. The
inspector lists the current thread, model, reasoning, access, and
mode. The list filter keeps only threads for the selected project.
Tests cover thread switch, new task, demo reply, inspector, and
history. The live launch wait looks for GPUIX chat demo so the quoted
user text cannot match twice.
Session: ses_f61007e8fffe0tRyMx0uRPPeCZ
The previous array form could not grow. Extra fields such as scroll
offsets would have to sit at magic indexes. Native napi, wasm, the test
renderer, automation, and every call site now use named fields.
Session: ses_f83f90209ffeVmkdjhaBehDTP7
Select no longer walks children by `child.type === SelectItem`. Root `items`
is optional and only a label lookup for SelectValue while the popup is
closed. Keyboard nav and clicks read mounted SelectItem children through
registerItem. Disabled lives on Item. A styled wrapper around Item works.
Without items, SelectValue shows the raw value.
Register items in useLayoutEffect, not a callback ref, so a middle item
can re-render without jumping to the end of the list. selectValue rejects
unmounted and root-disabled items. A late-mounted selected item becomes
the highlight.
Native:
- getFocusedElementId, focusNextWithin, focusPreviousWithin walk GPUI's
painted TabStopMap via focus_next_among / focus_prev_among
- visibility: hidden maps to GPUI invisible()
- getElementBounds is typed on NativeRenderer
JS:
- export Props, GpuixTheme, and host prop types from @gpuix/react
- export FloatingLayer, useFocusTrap, and friends from @gpuix/react/floating
AGENTS.md: built-in headless controls follow Base UI's Root-data vs
children split.
Fixes#59
Session: ses_f83f90209ffeVmkdjhaBehDTP7
A raw Mach-O has no icon. cargo-packager wraps the binary in a .app.
Pass an .icns, not a 1024 PNG. cargo-packager rejected a 1024 PNG with
No matching IconType.
examples/packager.json packs the Bun chat compile as one main binary.
hermes/packager.json lists gpuix-hermes as main and the .node as a
second binary, not a resource. Resources land in Contents/Resources.
Hermes dlopen looks next to the exe, which is Contents/MacOS.
README documents the flow: install cargo-packager, build AppIcon.icns
with sips + iconutil, then cargo packager --release --config packager.json.
formats is host OS only (app/dmg, nsis, appimage). On this machine the
Bun chat .app is 82 MB and the Hermes counter .app is 34 MB.
Ignore packager output: examples/bundle/, examples/AppIcon.icns,
hermes/bundle/, hermes/AppIcon.icns, and tmp-icons/. Do not track
generated hermes/chat.cjs.
Session: ses_f84b88b16ffePB3X7Fqzot47OY
The homepage README now opens on the Superhuman-style mail client
sitting on the desktop wallpaper, not the chat window. Alt text is
`mail.tax example`. The launch snippet is `bun --hot mail.tsx`.
The committed JPEG is a window capture composited onto the Tahoe
wallpaper so the lake shows around the rounded window. Full-screen
capture cannot see other apps on this machine.
Also land the mail example itself: three panes, thread list, reading
pane, and the Framer newsletter assets.
Session: ses_f92fa1015ffeSFgm8TqqfEZnVb
Dispatch click, hover, wheel, and drag input through AnyWindowHandle on desktop and browser hosts. Mouse listeners can now update GpuixView during normal GPUI event delivery without triggering a nested entity lease and aborting the process.
Cover the production renderer with a child-process automation test that clicks the live chat sidebar, waits for the state change, then drives keyboard input through the composer.
Build and verify both the release native addon and the WebAssembly renderer so the ownership fix stays consistent across every live host.
Issue: #38
Session: ses_fa1aaf89dffezIsneAEezbytzc
Add a macOS frosted-glass dashboard that demonstrates GPUI's native blurred window backdrop, transparent titlebar, and translucent React surfaces.
Keep the root tint and glass cards low-opacity so the system vibrancy remains visible. Add a dedicated example command, document it in the examples table, and include a release changeset.
Validation:
- Type-check the example with TypeScript
- Run lintcn in examples
- Launch the native window and inspect a macOS window screenshot
Session: ses_fae3bcd26ffe4af01dEaLO6DG9
Consume the pending changesets into a minor release. Users get
stable GPUI identities on every host surface, background window
launch, live desktop fill/press, pixel-stable virtual-list
anchors, a single applyBatch transport, one prebuilt target per
OS, and CRLF-safe <code> rows.
CI publishes with bun, not npm, so workspace:^ never ships as a
literal string. React still rewrites @gpuix/native to the current
native version before publish, because bun pack otherwise keeps
the previous lockfile caret (^0.5.1 does not include 0.6.0).
The infinite-chat Windows assertion no longer requires the
viewport to be empty of message ids. On Windows the short window
still showed three ids after wheeling into the void; the test now
checks that the list anchor is on the trailing edge instead.
Fixes#25
Session: ses_fb1f5cd3fffeAe9VxtTeynTdjA
Make every native example honor GPUIX_BACKGROUND and make every current real-window automation example pass that flag. Human launches still focus normally, while agent-driven launches avoid taking the user keyboard on macOS and Windows.
Explain why live input, clicks, and screenshots do not require activation. Correct stale guidance that claimed fill and press were unavailable, and document that Linux currently ignores the focus option.
Validated with the examples and example-app test suites, type checking, lintcn, a live screenshot, and a macOS foreground-process check.
Session: ses_fb208083cffewN8NfeygDAdslz
Reduce the renderer mutation contract to one applyBatch call per React
commit across napi, wasm-bindgen, and the GPU-backed test renderer.
- replace the dynamic batching Proxy with an explicit commit-phase facade
- keep application commands on the original renderer instance
- remove direct create, append, style, text, prop, and commit entry points
- remove the unused removeChild and setCustomPropValue wire operations
- require raw JSON style and custom-prop values and boolean event flags
- regenerate native declarations and update architecture documentation
- update serialization benchmarks for the smaller protocol
- add a minor changeset for @gpuix/native and @gpuix/react
Validated with 204 Rust tests, 359 React tests, 47 example tests,
6 starter-app tests, release native and Wasm builds, lintcn, and both
serialization benchmark stages.
Session: ses_fb7d2b638ffen9PHc0FVU3DpAv
`bun scripts/web.ts` now registers one bundled document per example:
`/` keeps the chat and `/infinite` serves the infinite history. The
shared cross-origin isolation headers wrap each document the same way.
The inline page styles move from web.html into a shared web.css that
both documents import, and web-infinite-chat.{html,tsx} are the browser
entry pair for the example. bun.lock picks up string-dedent for the
example's seeded message generator.
Session: ses_fbc66676dffeMDwZ3jJhimdkw6
An older page landing while the reader waited in the loading row moved the
screen. Three stacked causes, three layers of fix:
1. gpui anchors a list on the item at the viewport top. While the reader
waits in the void that item IS the void, so the splice replaced the
screen under it. The old restore (`scrollToItem(listId, index)`) snapped
the anchor message to the viewport top with offset 0, itself a jump of
up to EDGE_HEIGHT pixels. No non-negative ListOffset can express "keep
this row where it is": the rows above it are freshly inserted and
unmeasured. The gpui fork (submodule commit 8b94defe56) now accepts a
negative `offset_in_item` in `ListState::scroll_to`; `layout_items`
resolves it by walking upward with real measured heights, which is what
makes the restore exact rather than estimate-based.
2. On the live renderer `scrollToItem` applied `scroll_to` immediately,
but the just-committed prepend only reaches the ListState at the next
`GpuixView::render`, so `splice_focusable` shifted the freshly restored
anchor a second time. The test renderer hid this because its wrapper
flushes first. Virtual-list scrolls are now queued in
`PENDING_VIRTUAL_LIST_SCROLLS` and applied right after that frame's
splice, then the queue is cleared (one-shot).
3. Scrolling DOWN jumped for a different reason: a reader resting at the
bottom sits on gpui's at-end sentinel (`logical_scroll_top` is None,
reported as itemIndex == item count), where any offset-in-void math is
meaningless. `getListScrollTop` returns
`[itemIndex, offsetInItemPx, viewportHeightPx]`; the app converts the
sentinel with `EDGE_HEIGHT - viewportHeight` so the void's old top edge
stays put and the new page fills the blank space below the text.
New API on all four surfaces (napi, UiCommand, wasm, test renderer):
- `scrollToItem(listId, index, offsetInItem?)` — px, may be negative
- `getListScrollTop(listId)` — logical anchor plus viewport height
`examples/infinite-chat.tsx` drops the snap-to-top workaround and restores
both directions. The loading row shrank from 560px to 160px.
Tests, each verified to fail against the previous behaviour:
- examples: "keeps the message under the reader at the same pixel when an
older page lands", "keeps the trailing messages at the same pixel when a
newer page lands", "lands a newer page inside the void the reader is
staring at"
- packages/react: scrollToItem offset roundtrip, negative-offset
normalization ([50,-100] over 40px rows resolves to [47,20]), top clamp
Session: ses_fbc66676dffeMDwZ3jJhimdkw6
Export the public host instance type for typed element refs.
Add a bounded virtual-list chat example with cursor pagination in both directions, stable prepends, variable-height Safe MDX rows, route links, loading states, and retained-page limits.
Include focused coverage for initial rendering, route navigation, paging to both ends, stable retained rows, and request termination at history boundaries.
Session: ses_fbcc7a504ffe9Ff4lP0KDJLp3B
`focus: false` shipped in the previous commit with no guidance on when to
reach for it. The answer is: whenever an agent starts the app. An agent that
launches the window to check its own work takes the keyboard away from
whoever is typing, mid-sentence, once per iteration, and it gains nothing by
doing so.
**README**
The `Background launch` section now leads with that rule, and a new
`Let an agent drive the app` section documents the environment-variable
pattern so a human run is unchanged and an agent run stays out of the way:
render(<App />, { focus: process.env.GPUIX_BACKGROUND !== '1' })
bun app.tsx # you: window comes to the front
GPUIX_BACKGROUND=1 bun app.tsx # agent: opens behind your editor
**AGENTS.md**
The same rule as an instruction, in `Drive the live window`, so an agent
follows it without being asked.
**examples/chat.tsx**
The AGENTS.md snippet launches `chat.tsx` with `GPUIX_BACKGROUND=1`, but
`chat.tsx` read no such variable, so the documented example would have
silently kept stealing focus. Its `render()` now honours it. This is the app
AGENTS.md tells agents to launch, so it is the one that most needs the flag.
**A claim I had to retract**
The first draft said typing works on a background window. It does not.
`fill()` and `press()` throw `keystrokes are not live yet`, because the live
renderer implements no `simulateKeystrokes`. That is a pre-existing `launch()`
gap and is unrelated to focus; it fails on a focused window too. All three
documents now say so and point at `createTestRoot()` for anything that types.
**Verified rather than assumed**
Every claim was run against a real launched window, then the probe deleted:
click() on an unfocused window works, count=2 after two clicks
screenshot() on an unfocused window works
both on a show:false window works, never on screen
focus not stolen Chrome stayed frontmost
fill() / press() on a live process throws
The exact AGENTS.md snippet was then run end to end: it launched `chat.tsx`,
clicked `sidebar-collapse`, wrote a screenshot of the fully painted app, and
left Chrome frontmost throughout.
`examples` 36 tests pass.
Session: ses_fc17399a8ffeTt72KZetsU15ty
Dispatch synthetic key events through the focused GPUI window input pipeline on macOS and threaded desktop platforms. Expose the generated napi methods needed by launch automation.
Add live composer integration coverage and document fill and press behavior. Replace active Waku branding with GPUIX content and accurate React-to-GPUI architecture details.
Session: ses_fc015b322ffedMaROvgdRpVsuI
Build the browser chat entry as part of the documentation website's production
build. The web build now emits a minified `chat.js` bundle and its Wasm asset
into the website's ignored public directory, after rebuilding the native Wasm
and React package so deployed output cannot use stale generated code.
Keep the production destination fixed at `website/public/chat-example` rather
than accepting a caller-controlled directory. The build clears that generated
directory before bundling without allowing a malformed command to remove an
arbitrary repository path.
Serve the shared example HTML at `/chat-example/`, redirect the non-trailing
slash form, and set COOP and COEP on the document. Those headers make the page
cross-origin isolated, which the shared WebAssembly memory requires. Static
JavaScript and Wasm stay same-origin under the same route prefix.
Add an accessible loading screen while the browser downloads and initializes
the renderer, then remove it after GPUIX mounts. The same shell remains usable
for local browser development, while the production route swaps its TypeScript
entry for the generated JavaScript asset.
Ignore the generated website bundle and add a minor changeset for the new live
example at https://gpuix.dev/chat-example/.
Session: ses_fc04fcb1dffeooxK1KAopXSTAY
Two more assumptions that only held on a developer machine, both found
by the new Windows job and by a macOS runner that shrank to 1024x768.
The timeline test computed every hit point from a hardcoded 1280x800.
The window is a real platform window, so a smaller display clamped it to
1024x642 and the ruler, the playhead and the marquee were all clicked
below the bottom edge. It now opens one probe window, reads the size the
platform granted, and derives the viewport props and the geometry from
that. Verified by pinning the probe to 1024x642 locally: all 19 pass.
The font test asserted that `fontFamily: "Menlo"` changes the picture.
Menlo is macOS only, so on Windows it fell back to the default and the
two screenshots were byte-identical, which is the test doing its job on
a family the host does not have. `MONO_FAMILY` names a real monospace
per platform instead.
Session: ses_fc0b7cd62ffe3aBw0pyYBOBDjf
`/tmp` does not exist on Windows, and native `save()` never creates the
parent directory, so every capture in styles, events, img and the diff
example failed the whole test there with
Failed to save screenshot: The system cannot find the path
specified. (os error 3)
That is 34 of the 41 failures in the new Windows test job.
`SHOTS_DIR` already pointed at packages/react/screenshots for exactly
this reason, so the fix is to use it, and to create it where it is
defined rather than in a `beforeAll` each file had to remember. The
examples diff test gets the same treatment against
examples/screenshots.
Session: ses_fc0b7cd62ffe3aBw0pyYBOBDjf
An edit to `examples/chat.tsx` now hot-updates the running page instead of
reloading it. Components swap in place and `useState` survives, so the GPUI
canvas, the composer text and the scroll position all stay where they were.
A reload re-downloads a ~19 MB Wasm module; this avoids that entirely.
`scripts/web.ts` drops the `Bun.build` step and the hand-written static file
map. It now registers `examples/web.html` as a Bun HTML route with
`development: { hmr: true }`, so Bun bundles the page, watches the graph and
applies the React Fast Refresh transform.
Fast Refresh never talks to a renderer directly. It goes through the React
DevTools global hook:
Bun HMR runtime
-> refreshRuntime.injectIntoGlobalHook(window)
@gpuix/react reconciler.ts
-> reconciler.injectIntoDevTools()
-> hands the hook scheduleRefresh + setRefreshHandler
react-refresh
-> performReactRefresh() -> scheduleRefresh(root)
That injection already existed. What was missing was a bundler that runs the
transform, and a test proving the wiring holds.
`examples/web-chat.tsx` must NOT call `import.meta.hot.accept("./chat", ...)`.
Bun runs an importer's dependency-accept callback even when the imported module
already self-accepted for Fast Refresh, so the callback remounted the tree on
top of a successful refresh and wiped every hook. That looks exactly like Fast
Refresh being broken, and it is not.
The Wasm half is a singleton: `WebGpuixRenderer::init` fails with "GPUIX web is
already running" once its thread-local app exists, and gpui_web appends its own
canvas to <body>. Living in node_modules is not what protects it, because Bun
bundles it into the same client registry. What protects it is that Bun re-runs
only the changed module and then walks upward through its importers, so an
unchanged dependency stays evaluated and cached.
packages/native/.cargo/config.toml links with `--shared-memory`, so the
generated `WebAssembly.Memory` is `shared: true` and the document must be
cross-origin isolated. `Bun.serve` has no way to add headers to an HTML route
(oven-sh/bun#16873), so the bundled document is registered on a private path and
re-sent from `/` with COOP + COEP. Only the top-level document needs them:
`require-corp` constrains cross-origin subresources, and every asset the dev
server emits is same-origin.
`packages/react/src/__tests__/fast-refresh.test.tsx` mounts through the GPU test
renderer, clicks to set state, swaps the component family, and asserts the new
output keeps the old state. It fails when `injectIntoDevTools()` is removed.
This matters because the failure mode is silent. Without the injection there is
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.
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 reports `false`.
- `--build-only` / `--rebuild` / auto-build-if-missing in scripts/web.ts, and
the missing check tests both wasm-bindgen outputs, not just the `.wasm`
- `examples/tsconfig.json` gets `noEmit: true`; nothing there is ever compiled
by tsc and a stray `tsc -p .` littered a `.js` next to every `.tsx`
- `@types/bun` as a root devDependency plus a `typeRoots` entry so
`scripts/tsconfig.json` types `Bun.serve` and the `*.html` bundler import
- README documents that automation locator bounds are canvas pixels, not CSS
pixels. Do not use `window.devicePixelRatio`; a tool can override the viewport
scale after GPUI has sized its canvas. Use `canvas.width / canvas.clientWidth`
Validated in Chrome with playwriter: text edits and a module-level colour
constant edit both repaint the canvas with state preserved, and a revert
restores a byte-identical screenshot.
Session: ses_fc233909fffeHxAGjrf2jkWSet
A `highlight` prop on any element paints a background wash behind matched or
explicitly given text ranges. It scopes to the declaring element's subtree, so
the root searches the window and a container searches that container. Nothing
resolves and nothing paints unless an element declares one.
<div highlight={{ query: 'fox' }}>
<text>the quick brown fox</text>
<code code={source} language="ts" />
</div>
Every string GPUIX paints already funnels through `crate::text`, so one hook
there reaches `<text>`, `<code>`, `<markdown>` and `<diff>` with no per-element
props. `useTextSearch` drives a find bar; `findRanges` is the same matcher in JS
for row data a virtual list never mounted.
There is deliberately NO joined document for a query
--------------------------------------------------
`shouldSetTextContent` is false, so React makes a separate host text node for
every interpolated string. `<text>Hello {name}!</text>` is three painted runs of
one logical line, and a per-node matcher cannot find `Hello Tommy`. So text has
to be merged before matching.
Merging the WHOLE subtree into one string was the obvious approach and it is
wrong: a 5k-row chat becomes one megabyte string, and the query is part of the
cache key, so a find-bar keystroke rebuilds it. Instead `GroupList::collect`
merges only what React split: adjacent primitive text children of one parent.
A match never crosses a group, which is what a browser does across a paragraph
boundary. A joined document is built only when a spec supplies explicit
`ranges`, because only then does anyone index into it.
Grouping is structural. It never reads `display`, which only knows `flex` and
`grid` here, and every text node already sits inside a `div`.
Two caches, and why search_revision exists
------------------------------------------
GroupList keyed by search_revision (walk + fold)
MatchSet keyed by search_revision + matcher (scan)
washes derived colorize() (O(matches))
`highlight` is itself a custom prop, so `subtree_revision` moves on every
keystroke. Keying the group list on it makes the cache do nothing for the one
case it exists for. `search_revision` moves on text, structure, and a nested
declaration appearing or disappearing; not on style, not on a native element's
own props, not on the query.
`matcher_hash` excludes `activeIndex` and the colours, so moving a find cursor
only re-colours matches that were already located.
A timing budget cannot police 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 `<text>`. `highlight_cache_tests` in `renderer.rs` compares
`Arc` identity instead and fails outright.
Native elements match their own painted string
----------------------------------------------
`<code>`, `<markdown>` and `<diff>` build text inside `render()`, so the
build-time resolver never sees it. Rather than a second traversal that
re-derives their text and `sub` values, which would drift from markdown's
render-time counter, they match the exact string they are about to paint
through `washes_for_native_run`.
Their matches share one ordinal sequence per declaration, continuing after the
retained matches via `NativeHighlight::offsets` and memoised per run so a row
gpui paints twice keeps its numbers. Numbering each run from zero made
`activeIndex: 1` mark the second match of every code line active.
Copy grouping, and destroy_element
----------------------------------
Two pre-existing bugs surfaced while building this, each with its own changeset.
`join_spans` put a newline between every span, so copying the interpolated line
above yielded `"Hello\nTommy\n!"`. Runs now carry a group and same-group runs
join with nothing. Copy and search share `search::group_id`, because deriving
one from `element.parent` and the other from adjacency silently diverges when a
non-text sibling sits between two leaves.
`destroy_element` never unlinked the child from its parent's `children` and
never called `mark_changed`. React sends `removeChild` first so it was hidden,
but the batch and napi APIs both allow a direct destroy.
Contract
--------
Matches are non-overlapping and leftmost-first. Case-insensitive matching uses
Unicode lowercasing, not full case folding, so `ff` does not match `ff`; `fold`
carries an offset map because lowercasing changes byte length. Explicit ranges
are UTF-16 code units and a boundary inside a surrogate pair is rejected, never
snapped. A word boundary is any code point that is not a letter, a digit, or
`_`. `userSelect: "none"` does not opt out of search, because a browser still
finds that text; only real chrome does.
`onHighlight` is queued during build and flushed after the root build returns,
keyed on `MatchSet::identity()`. 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.
Testing
-------
A wash is a quad, so `getPaintedText()` cannot see it. `getPaintedHighlights()`
reports the matched range in UTF-16 units plus the boxes drawn, one per visual
row, on all three renderers including the browser build.
167 native tests, 305 react, 17 examples.
Known limits, documented rather than hidden: `onHighlight` counts retained text
only, and a `<virtual-list>` row that was never built cannot be matched,
counted, or scrolled to. `findRanges` exists for exactly that.
Session: ses_fc21bea16ffe1AkeGWr9j3cQTa
The pan perf test never called `flush()`, so it timed the React update and
none of the GPUI build, layout and paint that follows. That hid the single
most important fact about a pannable surface.
| Wheel pan, 3,259 clips over 26 tracks | p50 |
|---|---|
| Culled | **7.7 ms** |
| `memo(ClipLayer)` only, no culling | **92 ms** |
| `memo` only, as the old test reported it | 0.6 ms |
`<virtual-list>` is the only thing that virtualizes. A surface that owns its
own offset places children absolutely, so GPUI lays out every retained child
every frame. `memo` and culling fix different halves:
```
memo(Layer) ► cuts React work and the applyBatch mutations
cull in JS ► cuts GPUI build, Taffy layout, and paint
```
The cull-off case now has a budget too, so the culled path cannot quietly
drift into it, and the drag samples use the new unflushed
`dispatchMouseMove` so one sample is one frame, like the pan.
Three correctness fixes in the example:
- a clip could be dragged past the last frame of the project, where nothing
could reach it again. Both the wanted position and the snapped position
clamp now, and a snap the clamp overrode no longer paints a guide
- the playhead could scrub past both ends
- collapsing a track only hid its clips and left an empty band. The row now
shrinks to an 18px header strip and everything below moves up
Documentation corrections, all from the same review:
- `AGENTS.md` still said to keep `occlude()` for absolute overlays, which
contradicts the rule three sections down
- `BlockMouseExceptScroll` is not DOM bubbling. GPUI hit-tests one flat list
of painted boxes, so the wheel reaches **any** scroller behind the
element, not only an ancestor. A new test covers an absolute card over an
unrelated sibling scroller
- `pointerEvents: "none"` stops the element blocking hitboxes behind it. It
does not disable that element's own listeners
New README material worth keeping: the culling section above, the
left-button capture rule, and the readout pattern. Every timeline test
paints its state into a `<text testId>` and asserts an exact number, which
survives a layout tweak far better than a screenshot diff.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
```bash
cd examples && bun --hot timeline.tsx
```
The question was whether GPUIX can carry a real editing surface, so the
example drags clips between tracks, trims both edges with snapping,
scrubs a playhead, marquee-selects, zooms under the pointer, and pans on
both axes with a frozen ruler and a frozen track column. Data is mocked
from a seeded generator, so every run paints the same project.
```
┌──────────────────────┬─────────────────────────────────────────────┐
│ tools 00:03:50 │ Ruler 0 5 10 15 20 25 │ ◄ pans X
├──────────────────────┼─────────────────────────────────────────────┤
│ Caption │ ▓▓▓▓ ▓▓▓ ▓▓▓▓▓▓▓ │
│ ▸ Track 10 │ █████████████ ▎playhead │ ◄ pans X and Y
│ ▸ Track 9 │ ▓▓▓▓ ▓▓ │
│ Audio 1 │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ │
│ ▲ pans Y │ │
└──────────────────────┴─────────────────────────────────────────────┘
```
Two patterns in it are worth copying.
**React owns the scroll offset.** A native `overflow: "scroll"` grid
cannot drive a frozen header: GPUI moves the grid on the wheel frame,
and the `onScroll` callback that would move the ruler lands a frame
later, so the two tear apart during a fast pan. One `onScroll` listener
on a non-scrolling parent keeps `scrollX` and `scrollY` in state, and
all three panes translate from the same numbers. Zed does the same. A
media-bin panel beside the preview stays on the native two-axis path for
comparison.
Each translating wrapper sets `pointerEvents: "none"`, because a
positioned box takes hits with no fill and would otherwise swallow every
press meant for the ruler or the empty grid behind it.
**A drag needs no overlay.** Every clip, trim handle, the ruler, the grid
and the zoom slider carry `onMouseDown`, `onMouseMove` and `onMouseUp`,
which arms GPUI's pointer capture, so a release past the window edge
still ends the gesture. A full-window overlay mounted on the press cannot
do that: capture is armed by the press, and the overlay does not exist
yet at that moment. I built the overlay first and it failed exactly that
test.
`timeline.test.tsx` drives all of it through the automation API and
asserts exact numbers read from three `<text testId>` readouts, not
screenshots. `timeline.perf.test.tsx` times mount, pan and drag on a
3,259-clip, 26-track project:
| Sample | p95 |
|---|---|
| mount | 131ms |
| pan, culling on | 1.90ms |
| pan, culling off | 0.64ms |
| drag move | 11.7ms |
Drag samples are not comparable to pan samples.
`nativeSimulateMouseMove` flushes before and after the event, so each one
contains two complete GPUI paints.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
The `applyBatch` path looked like the obvious place to speed up a large
mount, so measure both halves on the real `ChatApp` queue instead of
guessing. `examples/bench-serialization.ts` times the JS encode, and
`packages/native/examples/bench_serde.rs` times the Rust decode and the
resident tree.
The answer is that the codec is the **smallest** lever:
| Finding | Number |
|---|---|
| Allocations during decode | 1.76M for 221k ops |
| `StyleDesc` size | 1392 B, so a typed `Vec<Op>` gets 1408 B wide |
| `RetainedElement` resident | 3102 B, 5.4x smaller behind an `Arc` |
| Ops that actually parse a style | 90 of 221k |
`batch_payload` deep-clones every style payload and then `from_value`
parses it again. Interning styles in the protocol beats any codec swap,
and a hand-packed compact style buys nothing once they are interned.
No mainstream JS-Rust codec deduplicates repeated string *values*.
msgpackr `useRecords` deduplicates keys and emits a format `rmp_serde`
cannot read, so value dedup has to happen in the protocol.
`retained_tree` and `style` become `pub` so the bench measures the real
types instead of a copy that silently drifts. `rmp-serde` is a
dev-dependency only; the shipped addon still speaks JSON.
The same `Cargo.toml` and `lib.rs` hunks also carry the
`gpui_platform/test-support` feature and the Windows `cfg` for the test
renderer, because those edits sit inside the same hunks.
Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
`<code>` no longer paints a fill, border, radius, padding or language header, so
the two examples that showed a card now build one.
**chat.tsx** gets a `CodeBlock` wrapper used by both call sites, the transcript
rows and the safe-mdx `renderNode` override, so a fenced block in a markdown
turn and a raw code turn look the same. It drops `codeRadius` and
`codeHeaderTextSize` from `CHAT_THEME.metrics`: those fields are `mdCode*` now
and only reach the `<markdown>` fenced block, which the chat renders through
safe-mdx instead.
**native-text.tsx** gets the same wrapper for its CODE tab.
The mount budget in `chat.perf.test.tsx` goes 150ms to 170ms. The card is three
extra host nodes per code turn now, about 6ms over 1000 turns. Chrome costs
React nodes instead of native paint, and that is the point of the change.
Session: ses_fc1eac9e3ffewHyLo8c2O8kvvc
A long press in the mobile web build highlighted the whole screen and popped
the iOS callout. Two browser behaviours, both on the host page:
- **Selection.** The app draws every glyph on the canvas, so a long press
found no text and selected the whole `body` instead.
- **Tap highlight.** `-webkit-tap-highlight-color` paints a box over the
tapped element, and the canvas fills the window.
`contextmenu` was already prevented in `gpui_web/src/events.rs`, so only the
CSS side was missing. Long press now belongs to GPUIX, which handles
selection itself.
`[data-gpui-input]` keeps `user-select: text`: `gpui_web/src/ime_mirror.rs`
writes a real selection range into that hidden textarea for composition,
backspace context, and autocorrect. A blanket `user-select: none` would break
IME on iOS and Android.
Verified in Chrome against the running example:
| Element | user-select | tap highlight |
|---|---|---|
| `body` | none | rgba(0,0,0,0) |
| `canvas` | none | rgba(0,0,0,0) |
| `[data-gpui-input]` | text | rgba(0,0,0,0) |
This fixes the example page only. The stronger place is `prepare_canvas` in
`zed/crates/gpui_web/src/window.rs`, next to the `touch-action: none` GPUI
already sets, so every host page inherits it.
Recommitted after an accidental reset in a parallel session; content is
identical to the original f99ad81.
Session: ses_fc26d592affeZYo4Ozf6yC9ilK
An iOS keyboard covers the bottom of the window, so the chat composer
sat behind it with no way for an app to know. GPUI reports the geometry
through WindowInsets; nothing surfaced it to JS.
- getWindowInsets() on both the napi and Wasm renderers, returning
safeArea, ime, and effective edges.
- useWindowInsets() samples that every 100ms and only rerenders when
the numbers change. Safari fires visualViewport events in bursts
while the keyboard animates and is documented to report stale values
on some of them, so polling is steadier than subscribing. Pass
intervalMs to slow it down, or false to read once.
- The hook also derives keyboardTop, keyboardVisible, and visibleHeight.
- examples/chat.tsx pads its content column by ime.bottom, so the
composer and workspace footer stay above the keyboard.
index.d.ts is regenerated by napi from the new #[napi] types.
Session: ses_fd13bc1a1ffecH1QUVugD0YnOj
render({ debugFrameOverlay }) only worked on desktop. The Wasm renderer
had no binding at all, and the React renderer silently skipped the call
through optional chaining, so the browser had no way to see draw time.
- Turn on gpui's profiler feature. DebugFrameOverlayMode and
set_debug_frame_overlay_mode sit behind it, and hdrhistogram builds
for wasm32-unknown-unknown.
- Export setDebugFrameOverlay and getDebugFrameOverlay from
WebGpuixRenderer.
- Queue the mode when the GPUI window does not exist yet. Browser
graphics init is async, so render() sets the overlay before
WEB_WINDOW is populated; open_window applies the pending mode on the
first paint. Without this the setter threw during ChatApp render and
the React tree never mounted, leaving a black page.
- Share the mode parser between napi and wasm instead of duplicating
the string match.
examples/web-chat.tsx turns full mode on.
Session: ses_fc2a313e9ffebyZow7iy5ZvYDR
GPUI does not dispatch TouchEvent into a gesture arena yet, so gpui_web
forwarded every pointer as a mouse event. Combined with the canvas
touch-action: none, an iOS swipe became a text selection and nothing
scrolled. A composer tap opened the keyboard and lost it immediately.
The gpui_web side of this lives in the zed submodule, which this commit
moves to 773208d. It maps touch pans to ScrollWheelEvent, holds
mouse-down until a long press, tracks one active touch pointer, and
handles pointercancel and lostpointercapture.
Native side:
- A tap only arms the selection. It no longer selects or blurs, so a
swipe scrolls and a composer tap keeps focus. The first dragging move
promotes the armed press into a real drag and blurs then, which keeps
Cmd+C copying painted text (see 95545aa).
- <input> and <textarea> call window.request_text_input() from their
mouse-down handler. gpui_web reads that synchronous intent instead of
the previous frame's input handler, so the keyboard opens during the
browser user gesture.
- web.html no longer pins the hidden IME field to (0,0). Rust parks it
on the caret; a clipped 1px box at the origin makes iOS drop the
keyboard.
Verified in the browser build with Playwriter, driving synthetic touch
pointer events against the wasm renderer:
| Gesture | Result |
| ----------------- | ----------------------------------------- |
| pan | scrolls both ways, selection stays null |
| composer tap | readOnly false and focused, keyboard opens|
| tap elsewhere | readOnly true, keyboard closes |
| long press | selects the word under the finger |
| long press + drag | extends the selection |
Not verified on real hardware. Touch pans still have no momentum;
GPUI's touch arena (zed-industries/zed#63068) excludes it.
Session: ses_fc2a313e9ffebyZow7iy5ZvYDR
GPUI Web keeps a focused DOM input for clipboard and composition. It only
used a 1px opacity-0 box at top:0, so host `input` CSS made a native text
field show at the top of the page.
Point zed at the gpuix fork commit that forces the hide with important
inline styles, clip-path, and autocomplete=off. The browser example CSS
matches that so a refresh hides it without a Wasm rebuild.
Bridge GPUI events back to React through a queued Wasm callback. Event payloads keep the desktop camelCase shape, and microtask delivery prevents React mutations from re-entering GPUI during native event dispatch.
Point the Zed submodule at the browser-safe GPUI gesture clock so precise wheel input no longer panics on wasm32-unknown-unknown.
Restore the complete web chat transcript with React-composed Markdown, tables, code, and native diffs. Sidebar controls, selects, comboboxes, inputs, and scrolling now work in the browser.
Validation: Wasm release build, 108 native tests, 258 React tests, 17 functional example tests, six GPUI gesture tests, rustfmt, lintcn, and Playwriter clicks and scrolling through BrowserWebGPU.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a
Follow the virtual-list children contract introduced on main and keep the six-turn browser transcript regression check focused on the retained child count.
Validation: chat and diff example tests pass; the full Wasm build, native build, Rust tests, React build, and React tests also pass.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a
Resolve the duplicate wasm dependency table introduced by the rebase and use web-time for automation and motion clocks so wasm32-unknown-unknown compiles cleanly.
Guard Node stdin automation in browser bundles, render a bounded six-turn web transcript, and validate the small-turn transcript contract with the native renderer.
Validation includes the release native addon, full Wasm and browser bundle, BrowserWebGPU runtime loading in Chrome, 108 Rust tests, 258 React tests, 21 example tests, rustfmt, and lintcn.
Session: ses_fd23d320cffeTXMP6XvLBmNC2a