GPUI Windows
A retend-gpui application has two layers. The application is one Node.js process that owns shared resources such as a database connection. Each window is an independent Retend root that renders the entry component.
Configuring the Plugin
retend-gpui dev and vite build both read the retendGpui plugin in vite.config.ts:
import { retendGpui } from 'retend-gpui/plugins/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ retendGpui({ app: { name: 'Notes', identifier: 'com.example.notes', version: '1.0.0', icon: './assets/icon.png', }, application: './source/application.ts', entry: './source/main.tsx', system: { transparentTitlebar: true }, window: { width: 900, height: 640 }, }), ], });
| Option | Required | Description |
|---|---|---|
app | Yes | The application's identity. See below. |
application | Yes | Path to a module whose default export is the application class. |
entry | Yes | Path to a module whose default export is the root component. |
window | Yes | Options for the first window. width and height are required. |
system | No | Process-wide native options. See System Options. |
target | No | Build target. See GPUI Packaging. |
node | No | Embedded Node.js version for packaged apps. See GPUI Packaging. |
signing | No | macOS signing and notarization. See GPUI Packaging. |
Paths are relative to the Vite root. The plugin validates its options when Vite starts and throws a descriptive error if one is wrong.
App identity
| Field | Required | Description |
|---|---|---|
name | Yes | The display name. Also the default window title. |
identifier | Yes | A reverse-DNS identifier such as com.example.notes. |
version | Yes | A valid SemVer version, such as 1.0.0. |
icon | No | The app icon. macOS accepts .icns, .png, or .svg. Windows accepts .ico, .png, or .svg. |
macos.icon | No | Overrides icon on macOS. |
windows.icon | No | Overrides icon on Windows. |
description | No | A description passed to the Windows packager. |
publisher | No | A publisher name passed to the Windows packager. |
The Application Class
The module at application default-exports a class. It holds process-wide state in a context object and sets it up in init():
import type { GpuiApplication } from 'retend-gpui'; interface AppContext { database: Database | null; } export default class Application implements GpuiApplication<AppContext> { readonly context: AppContext = { database: null }; async init() { this.context.database = await openDatabase(); } async cleanup() { await this.context.database?.close(); } }
init()runs once, when the application starts.cleanup()runs when the application shuts down.contextkeeps the same object identity from construction through cleanup. It is mutated rather than replaced.
Both methods may be asynchronous.
Reading the Application Context
useAppContext() returns the context object from any component:
import { useAppContext, useWindow } from 'retend-gpui'; export default function App() { const { database } = useAppContext(); const window = useWindow(); return <div>{database ? window.title : 'Database unavailable'}</div>; }
The context is typed from the application class. retend-start creates retend-gpui-env.d.ts for this, and every dev or build startup checks it and rewrites it only when it is missing or stale. Changing the application path repairs the types automatically. For projects maintained by hand, the file is added to the tsconfig include and committed, as with next-env.d.ts.
useAppContext() throws when no application is active.
The Window Object
useWindow() returns the native window for the current Retend root:
| Member | Description |
|---|---|
width, height | Read-only Cells that track the content size. They update together on native resize events. |
orientation | Read-only Cell with portrait or landscape, derived from width and height. Square windows are portrait. |
title | A Cell. Setting it changes the OS window title. |
initialPath | The route path the window opened on, from the initialPath option. It never changes. |
open(options) | Opens another independent window. Returns a promise of a handle. |
showMenu(options) | Shows an OS-native popup menu and resolves to its selected item ID or null on dismissal. |
close() | Requests that this window close. |
host | The native host. It dispatches window lifecycle events. |
The window is also an event target and emits focus and blur.
import { Cell } from 'retend'; import { useWindow } from 'retend-gpui'; function Header() { const window = useWindow(); const size = Cell.derived( () => `${window.width.get()} × ${window.height.get()} (${window.orientation.get()})` ); return ( <div> <div>{size}</div> <button onClick={() => window.title.set('Renamed')}>Rename window</button> </div> ); }
orientation is derived from the size, so it updates whenever a resize changes the aspect: landscape when the width exceeds the height, portrait otherwise.
Native Popup Menus
Use showMenu() for context menus and menus opened from buttons. It uses the operating system's menu appearance and keyboard navigation, not a GPUI-rendered dropdown.
import { useWindow } from 'retend-gpui'; function FileRow() { const window = useWindow(); async function openMenu(x: number, y: number) { const selected = await window.showMenu({ position: { x, y }, items: [ { id: 'open', label: 'Open' }, { id: 'rename', label: 'Rename' }, { type: 'separator' }, { id: 'delete', label: 'Move to Trash', disabled: true }, ], }); if (selected === 'open') openFile(); if (selected === 'rename') renameFile(); } return ( <div onContextMenu={(event) => void openMenu(event.clientX, event.clientY)}> Right-click a file </div> ); }
The position is in logical pixels relative to the window's content. Menus can also be opened programmatically, for example from a toolbar button. Each action needs a unique nonempty id and label; separators use { type: 'separator' }. Dismissing a menu without selecting an action resolves to null. TypeScript infers the permitted result IDs from an inline items array (for example, 'open' | 'rename' | 'delete' | null). Closing the window before presentation rejects the promise.
On input and textarea, Retend normally provides the OS editing menu. Registering an onContextMenu listener opts into replacing that default: the handler must call showMenu() if it wants a menu. Since JavaScript events are delivered asynchronously, preventDefault() does not cancel an already-open OS popup.
Native popup menus are supported on macOS and Windows. They do not currently provide custom JSX content or nested submenus. Linux does not support this API yet.
Window Options
The window option in the plugin config and the options argument to open() accept the same fields:
| Option | Type | Description |
|---|---|---|
title | string | The window title. For the first window it defaults to app.name. |
width | number | The initial content width, in logical pixels. |
height | number | The initial content height, in logical pixels. |
resizable | boolean | Whether the user can resize the window. Defaults to true. |
fullscreen | boolean | Opens in fullscreen. Cannot be combined with maximized. |
maximized | boolean | Opens maximized. Cannot be combined with fullscreen. |
minWidth, minHeight | number | The smallest content size the user can resize to. |
maxWidth, maxHeight | number | The largest content size the user can resize to. |
initialPath | string | The route path the window opens on, such as a deep link. Defaults to /. |
closeWithOpener | boolean | Whether the window closes when the window that opened it closes. Defaults to true. |
Sizes are finite positive numbers, and each minimum cannot exceed its maximum.
Opening More Windows
open() creates another native window with its own Retend root. An initialPath starts it on a specific route:
import { useWindow } from 'retend-gpui'; function OpenSettings() { const window = useWindow(); const openSettings = async () => { const settings = await window.open({ title: 'Settings', width: 520, height: 640, initialPath: '/settings', }); settings.addEventListener('close', () => console.log('Settings closed')); }; return <button onClick={openSettings}>Settings</button>; }
The returned handle has a close() method and fires a close event. By default a child window closes with the window that opened it. closeWithOpener: false keeps it open independently.
Routing
A GPUI window has no address bar or back and forward buttons, so the router is the only thing that changes the route while the app runs. attachRouter connects the router to the window. The scaffolded router.tsx shows the setup:
import { onSetup } from 'retend'; import { attachRouter, useWindow } from 'retend-gpui'; import { Outlet, Router, RouterProvider } from 'retend/router'; import App from './App'; const routes = [{ path: '/', component: App }]; export default function Root() { const window = useWindow(); const router = new Router({ routes, linkTag: 'div' }); onSetup(() => attachRouter(router, window)); return ( <RouterProvider router={router}> <Outlet /> </RouterProvider> ); }
Three details matter:
linkTag: 'div'givesLinka GPUI-compatible host element.attachRouternavigates towindow.initialPath. A window opened withinitialPath: '/books/42'starts on that page, which is how deep links work. If the root remounts, for example after a hot reload of this file, the new router starts again from the initial path.- Route titles set the window title. A route without a title leaves the current title in place, as in a browser.
attachRouterreturns a function that stops the title updates, whichonSetupuses as its cleanup.
See Defining Routes for the router itself.
System Preferences
useSystem() returns reactive operating-system preferences shared by every window in the process, including components rendered with renderToGpui():
import { Cell } from 'retend'; import { useSystem } from 'retend-gpui'; function ThemedBadge() { const system = useSystem(); const label = Cell.derived(() => system.theme.get() ?? 'detecting…'); return <div>Theme: {label}</div>; }
| Member | Description |
|---|---|
theme | A Cell with light, dark, or null until the native runtime reports the initial value. |
accentColor | A Cell with the system accent as a CSS hex color, or null before detection or on unsupported platforms. |
Both Cells start as null and update when the OS reports a change. accentColor is supported on macOS and Windows, where it follows the Windows UI accent preference rather than the DWM titlebar color. It stays null on platforms without a supported system accent API.
For imperative code, the window host also dispatches a systempreferences event carrying the same values:
import type { GpuiSystemPreferencesEvent } from 'retend-gpui'; window.host.addEventListener('systempreferences', (event) => { const preferences = event as GpuiSystemPreferencesEvent; console.log(preferences.theme, preferences.accentColor); });
System Options
system holds static options that apply to every window in the application (as opposed to the reactive preferences above):
| Option | Description |
|---|---|
transparentTitlebar | Makes the native titlebar transparent so content can extend behind the window controls. macOS and Windows. |
trafficLightPosition | { x, y } placement of macOS close, minimize, and zoom buttons, in logical pixels from the titlebar origin. Ignored on other platforms. |
Development Reloads
During retend-gpui dev, Vite runs without an HTTP server and starts one Node.js application process with the initial window.
- Editing a component updates it in place.
- Editing the application module, or a module it imports, triggers a full reload. This recreates application and module state and remounts every open window without replacing the native windows or their navigation history.
- Restarting Vite, for example after a config change, replaces the whole application process.