Developing a {name} application

These instructions were generated by {name} {version} for coding agents and people writing an application for it. They describe the runtime that generated them; regenerate them with {name} agents --dir <directory> after upgrading.

What the runtime is

{name} runs JavaScript applications natively on GPUI. There is no DOM, no HTML, no CSS, no browser and no Node.js. A script describes its interface with builder calls; Rust turns that description into real GPUI elements and renders it on the GPU. Do not reach for web APIs, React, JSX, npm packages that touch the DOM, or document/window.addEventListener — none of them exist.

The language is modern JavaScript (ES2022 modules) on QuickJS. Write .js files with JSDoc types; there is no build step and no bundler.

Application layout

my-app/
├── gpui-shell.json   # identity, entry, dependencies and capabilities
├── main.js           # entry module: default-exports a View subclass
├── jsconfig.json     # editor type checking (written on first run)
├── gpui-kit.d.ts     # generated declarations — never edit, never commit
└── icons/            # assets, resolved against the application root

{name} runs one application per process. An installed application is a directory under {home}: {name} run clock runs {home}/clock. Any other directory runs with {name} run -d <directory>. To ship the application as a macOS .app or a Linux package, place this directory as app beside the {name} executable (or Contents/Resources/app in a macOS bundle) and start the executable with no arguments.

gpui-shell.json

{
  "id": "com.example.clock",
  "name": "Clock",
  "version": "1.0.0",
  "shell-version": "{version}",
  "entry": "main.js",
  "capabilities": {
    "storage": true
  }
}

id, name and entry are required. id is the application‘s identity and keys its data directory, so keep it stable. shell-version is the oldest runtime the application supports. window is the size the window opens at, in logical pixels — "window": { "width": 360, "height": 520 } — and the host’s default when absent. Unknown fields are rejected.

The smallest application

import { View, div } from "gpui-kit";
import { v_flex, Button } from "gpui-base";

export default class Counter extends View {
  init(_props, _cx) {
    this.count = 0;
  }

  render(cx) {
    return v_flex()
      .size_full()
      .items_center()
      .justify_center()
      .gap(12)
      .bg(cx.theme().colors.background)
      .child(div().text_color(cx.theme().colors.foreground).child(`Count: ${this.count}`))
      .child(
        Button.new("increment")
          .px(14)
          .h(32)
          .rounded(6)
          .bg(cx.theme().colors.primary)
          .text_color(cx.theme().colors.primary_foreground)
          .on_click((_event, cx) => {
            this.count += 1;
            cx.notify();
          })
          .child("Increment"),
      );
  }
}

Built-in modules

{modules}

Import only these names, plus relative modules (./ui.js) and the bare dependency names declared in gpui-shell.json. The exact API — every element, style method, argument type and component — is in gpui-kit.d.ts, generated by {name} types <directory>. Read it before writing code and treat it as the source of truth over this guide.

Rules that are easy to get wrong

  • Host methods are snake_case (items_center, on_click, text_sm, Button.new("id")), exactly as in Rust. Your own code is camelCase.
  • Construct with Type.new(...) in every module — Button.new("save") from "gpui-base" and from "gpui-component" alike — never new Type(...). The arguments differ by module: gpui-base's InputState.new({ placeholder }) takes options, gpui-component's InputState.new(placeholder, value) takes them in order. gpui-kit.d.ts has each signature.
  • Assign state in init, not as class fields. View's constructor calls init from inside super(); a class field declaration runs afterwards and overwrites it with undefined.
  • render(cx) returns one element (or a string, or a retained view). It runs only when the view is invalidated, not every frame.
  • Call cx.notify() after changing state in an event handler or task. Calling it inside render throws.
  • Elements are single-use. Build them inside render; never store one on this or reuse it in two places.
  • Ids must be stable and unique among siblings for interactive controls: Button.new("save"), Checkbox.new(todo-${item.id}).
  • Controls are controlled. Checkbox/Switch take .checked(value) and report .on_change((checked, cx) => …); store the new value and notify.
  • Text state reports its own events. Create an InputState in init, pass it to Input.new(state) from the same module, and subscribe with state.on("change" | "submit" | "focus" | "blur", (event, cx) => …); submit is Enter, and event.shift says whether Shift was held. A TextareaState inserts the line break before it reports submit; gpui-base‘s has set_submit_on_enter(true) to make Enter only submit (Shift+Enter still inserts a line break, and still reports submit with shift: true). Both modules’ InputState and TextareaState answer on the same way, but each module‘s Input takes only its own module’s state.
  • Overlays are window calls made from an event handler or a task: window.open_dialog(() => element), window.open_sheet(...), window.push_toast({ title }). init runs before the window can show one, so they throw there.
  • Base controls draw nothing by themselves. Button.new("x") has no padding, colour or radius; every visual decision is written in the script.
  • Use theme tokens for colour: cx.theme().colors.surface, cx.theme().spacing.md. Read them at the use site; do not destructure the theme. #rrggbb literals bypass the theme and break dark mode.
  • A number length is pixels; a string length is "auto", "50%", "12px" or "1rem", as far as the Rust signature allows.
  • .bg(...) takes a colour or a Background from "gpui-kit": .bg(Background.linear_gradient(180, Background.stop(top, 0), Background.stop(bottom, 1))) — stop positions are fractions from 0 to 1. .shadow([...]) takes CSS-like records, { x, y, blur, spread, color, inset }, with CSS blur and CSS stacking (first on top); [] clears.
  • svg(path) and image(path) resolve against the application root, not the importing file. Keep assets in a directory named after the application.
  • Use .when(condition, (el) => el.…) to keep a builder chain in one piece.

State, events and asynchrony

  • cx.spawn(async (cx) => { … }) runs async work whose cx survives await; cx.sleep(ms), cx.timer.after and cx.timer.every schedule on the UI thread. There is no background thread for script.
  • localStorage / sessionStorage persist per application (needs "storage": true).
  • TextEncoder / TextDecoder (UTF-8) are globals and need no grant.
  • fetch (HTTP), WebSocket from "websocket", fs/promises, clipboard and process.run exist but are denied unless the manifest grants them. Ask for the narrowest grant that works:
"capabilities": {
  "fs": { "read": ["${pluginDir}"], "write": ["${dataDir}"] },
  "network": {
    "http": [{ "host": "api.example.com", "methods": ["GET"], "path_prefixes": ["/v1/"] }]
  },
  "storage": true,
  "clipboard": { "write": true }
}
  • fs roots may name the user's files with ${homeDir}, as in "read": ["${homeDir}/.config/my-app"], or be absolute paths.
  • os.homedir(), process.dataDir (the ${dataDir} directory) and import.meta.url (a file: URL) tell an application where things are. process.env holds common variables (HOME, USER, HOSTNAME, LANG, TZ, PATH, EDITOR, DISPLAY, HTTP_PROXY, the XDG directories…) by default, matched regardless of case; any other variable must be listed in "process": { "env": ["MY_APP_MODE"] }.
  • An application with a manifest keeps its data under its id, whether run with -d, installed or packaged.
  • process.run(command, args, { cwd, timeoutMs }) waits for a command to finish (30 s unless timeoutMs says otherwise). For one that runs longer, reads stdin, or should be read as it works — an AI command line in JSON-lines mode — use process.spawn. Same grant (capabilities.fs.execute), same empty environment; handlers run on the UI thread with a cx, exit fires once after the last output, and the child is killed when the view goes away, the application reloads or the window closes:
import process from "process";

const child = process.spawn("claude", ["-p", prompt, "--output-format", "stream-json"], {
  cwd: projectDir, // inside capabilities.fs.read
  lines: true,
});
child
  .on("stdout", (line, cx) => {
    if (!line.trim()) return;
    this.events.push(JSON.parse(line));
    cx.notify();
  })
  .on("exit", ({ code, signal, error }, cx) => {
    this.running = false;
    this.failure = error ?? (code === 0 ? null : `exited with ${code ?? signal}`);
    cx.notify();
  });
// child.kill() to stop it; { stdin: "pipe" } and child.write(text) / child.end() to talk to it.
  • console.log / console.error go to the terminal running {name}.
  • Heavy or rarely used code belongs in modules loaded with dynamic import() from the handler that needs it; keep the entry module light.

Workflow

{name} types -d my-app          # write gpui-kit.d.ts — do this first
{name} check -d my-app          # load, render and materialize once; exit 1 on error
{name} dev -d my-app             # run it, reloading on save
{name} list                      # what is installed under {home}
{name} run my-app               # run the installed copy

After every change, run {name} check -d <directory> and fix what it reports before declaring the work done. check does not exercise layout, paint, later interactions or asynchronous states, so also run the application with {name} dev when behaviour matters.

To install an application, copy or link its directory into {home}; the directory name is the name {name} runs it by. An installed application keeps its data under its manifest id, so a reinstall does not lose it.