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 },
    }),
  ],
});
OptionRequiredDescription
appYesThe application's identity. See below.
applicationYesPath to a module whose default export is the application class.
entryYesPath to a module whose default export is the root component.
windowYesOptions for the first window. width and height are required.
systemNoProcess-wide native options. See System Options.
targetNoBuild target. See GPUI Packaging.
nodeNoEmbedded Node.js version for packaged apps. See GPUI Packaging.
signingNomacOS 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

FieldRequiredDescription
nameYesThe display name. Also the default window title.
identifierYesA reverse-DNS identifier such as com.example.notes.
versionYesA valid SemVer version, such as 1.0.0.
iconNoThe app icon. macOS accepts .icns, .png, or .svg. Windows accepts .ico, .png, or .svg.
macos.iconNoOverrides icon on macOS.
windows.iconNoOverrides icon on Windows.
descriptionNoA description passed to the Windows packager.
publisherNoA 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.
  • context keeps 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:

MemberDescription
width, heightRead-only Cells that track the content size. They update together on native resize events.
orientationRead-only Cell with portrait or landscape, derived from width and height. Square windows are portrait.
titleA Cell. Setting it changes the OS window title.
initialPathThe 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.
hostThe 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:

OptionTypeDescription
titlestringThe window title. For the first window it defaults to app.name.
widthnumberThe initial content width, in logical pixels.
heightnumberThe initial content height, in logical pixels.
resizablebooleanWhether the user can resize the window. Defaults to true.
fullscreenbooleanOpens in fullscreen. Cannot be combined with maximized.
maximizedbooleanOpens maximized. Cannot be combined with fullscreen.
minWidth, minHeightnumberThe smallest content size the user can resize to.
maxWidth, maxHeightnumberThe largest content size the user can resize to.
initialPathstringThe route path the window opens on, such as a deep link. Defaults to /.
closeWithOpenerbooleanWhether 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' gives Link a GPUI-compatible host element.
  • attachRouter navigates to window.initialPath. A window opened with initialPath: '/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. attachRouter returns a function that stops the title updates, which onSetup uses 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>;
}
MemberDescription
themeA Cell with light, dark, or null until the native runtime reports the initial value.
accentColorA 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):

OptionDescription
transparentTitlebarMakes 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.