# Migrating `crates/ui` to `gpui-base` `crates/ui` is a fork of an early version of `gpui-component`: 71 files and roughly 21.9k lines that mix behavior, presentation, and application shell. This document is the plan for moving its behavior half onto the upstream `gpui-base` crate while the application keeps the design system it has today. The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-kit` repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, 2026-09-17). Line counts come from `wc -l` under `crates/ui/src`. ## The two facts that shape the work **GPUI still comes from upstream — addressed as the `gpui-pre` package.** `gpui-base` declares its GPUI dependency as `gpui = { package = "gpui-pre", version = "0.3.1" }`: the crate in the graph is the published package `gpui-pre`, and `gpui` is only the name used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`) republished unchanged, so nothing is forked and there is no source to align. Coop currently pins zed's git repository at `4b47ceb` (2026-09-17), roughly four days ahead of that snapshot. The two cannot be mixed. Zed's git `gpui` and the `gpui-pre` package are different crates, so `App`, `Window`, `Entity`, and elements from one are not the other's types, and a dependency graph that contains both does not compile. Zed's crates.io `gpui` (0.2.2, October 2025) is also far behind the APIs coop already uses. The workspace's `gpui` entry therefore has to resolve to the `gpui-pre` package; with the `package =` alias, every `use gpui::…` site stays as it is. **The fork's external contract is small.** Outside `crates/ui`, the crate is consumed as 38 imported items plus a single `ui::init(cx)` call, across 13 modules, and never deeper than `ui::::`: | Module | Items | Consumer files | | --- | --- | --- | | crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 | | `input` (`InputState`, `Input`, `InputEvent`) | 3 | 10 | | `button` (`Button`, `ButtonVariants`) | 2 | 14 | | `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 | | `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 | | `list`, `checkbox`, `popover`, `resizable`, `skeleton`, `tab`, `divider` (module), `history`, `animation`, `actions` | 0 references | 0 | `ui::list` and `ui::checkbox` have no consumers at all; the message list in `crates/chat_ui` uses GPUI's own `list::ListState`. The modules with zero external references still serve as internal machinery for `dock`, `menu`, `modal`, and `input`. The consequence: this is not a rewrite of an app-facing library. Most of the work is deleting internals and re-expressing a few thousand lines of presentation over base primitives. ## What must not change - `crates/theme` stays the source of truth: `ThemeColors`, `ThemeFamily`, the registry, scrollbar mode, platform, font size and radii. - Behavior comes from `gpui-base`; presentation comes from `theme` plus the `ui` styled layer. Every migrated component keeps reading `cx.theme()` and keeps its current spacing, radius, and shadow math, so the rendered result does not move. - Application code keeps importing `theme::ActiveTheme` and `ui::*` under its current names. Module paths are part of the contract; internals are not. - `gpui-component` is not adopted. It is a complete, styled visual language, and taking it would replace the design system rather than preserve it. Two `Theme` types will exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI globals. Coop's stays the application-facing one. Base's is touched in exactly one place: a `theme::sync_base(cx)` that projects coop's colors into `gpui_base::Theme::global_mut(cx).tokens` (`SemanticThemeTokens`: colors, radius, typography, shadow) plus `ThemeAppearance`, `ScrollbarTheme`, and `ResizableTheme`. It runs from `ui::init` and on every theme change. This is needed because base paints a few things itself — the focus ring from `FocusableExt`, text selection under glyphs, scrollbars, resize handles, and the dialog backdrop — and those should follow coop's palette rather than base's default. ## What each module becomes | `ui` module | LOC | Plan | `gpui-base` counterpart | | --- | --- | --- | --- | | `input/` (state, element, display_map, rope_ext, mask_pattern, movement, selection, indent, mode, change, cursor, blink_cursor, clear_button) | 6,929 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path | `Input`/`InputState`, `Textarea`/`TextareaState`, `Editor` | | `list/` | 1,477 | Delete | GPUI's own `list` (already in use) | | `checkbox.rs` | 312 | Delete | `Checkbox` | | `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` | | `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline | | `modal.rs` | 540 | Port onto base parts; keep `Modal`, `ModalButtonProps`, and `window.open_modal` | `Dialog`, `AlertDialog` | | `notification.rs` | 584 | Port; keep `Notification`, `NotificationKind`, and `window.push_notification` | `Toast`, `ToastManager`, `ToastStack` | | `popover.rs` | 432 | Replace with a coop-styled wrapper | `Popover`, `Popup`, `Positioner` | | `tooltip.rs` | 36 | Replace with a coop-styled wrapper | `Tooltip` | | `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | | `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | | `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | | `history.rs`, `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 340 | Delete | `History`/`UndoHistory`, `IndexPath`, `ElementExt`, `InteractiveElementExt`, `FocusableExt`, `FocusTrapElement` | | `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import; base's `h_flex`/`v_flex` helpers are identical (`flex_row` + `items_center`) and can be delegated to | `styled`, `StateStyle` | | `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — | | `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) | | `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) | | `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers and `focused_input` | — | Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept. ## Dependency change The workspace manifest's GPUI entries become: ```toml [workspace.dependencies] gpui = { package = "gpui-pre", version = "0.3.5" } gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" } gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" } gpui_web = { package = "gpui-pre-web", version = "0.3.5" } reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } gpui-base = "0.6.1" ``` Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep compiling unchanged. `gpui_web` moves from `web/Cargo.toml` into the workspace table with the rest. The only alternative — leaving the workspace on zed's git `gpui` and redirecting `gpui-base`'s dependency to it — means vendoring `gpui-base` and owning its source. That is a fork, and this plan deliberately avoids it. `gpui_tokio` is the one missing piece: longbridge does not republish it, and `crates/state` uses it in three places (`init`, `spawn`, `spawn_result`). Either vendor zed's small crate into the workspace, or drop it for `cx.background_spawn`. Decide in phase 0. `gpui-base` and `gpui-pre` move together on minor versions (`0.6.x` requires `0.3.x`); bump both in the same change. ## Phases ### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) Point the workspace's GPUI entries at the published `gpui-pre` crates and fix whatever the four days of API drift between `4b47ceb` and `zed@d89e9c2` broke. There is no GPUI source to align, patch, or vendor. Confirm that the entry points coop calls still exist in 0.3.5: `gpui_platform::application()`, `web_init()`, and `single_threaded_web()`. Exit criteria: `cargo check` passes for `desktop` and for `cargo check -p coop_web --target wasm32-unknown-unknown`, and the drift fixes are listed in the pull request. The change rewrites the dependency graph, so it stays in a pull request of its own. ### Phase 1 — Wire base, delete dead weight (no visual change) Add `gpui-base`, make `ui::init` call `gpui_base::init(cx)` followed by `theme::sync_base(cx)`, and re-export the base utilities the app already imports under their current names (`ElementExt`, `InteractiveElementExt`, `IndexPath`, `History`, `Disableable`, `Selectable`). Delete `checkbox.rs` and `list/`, which have no consumers, along with `history.rs`, `index_path.rs`, `element_ext.rs`, and `event.rs` once base supplies them. Drop the dependencies this leaves unused. Exit criteria: no diff outside `crates/ui` and `crates/theme`, the app launches, and switching the theme still restyles everything. ### Phase 2 — `input/` (the largest single win, ~6.9k lines) The mapping is close to 1:1 with what the app actually uses: | Coop today | `gpui-base` | | --- | --- | | `InputState::new(window, cx).placeholder(..)` | same | | `.auto_grow(1, 20)` (chat composer) | `TextareaState::auto_grow(2, 8)` with `Textarea` | | `.masked(true)` (nsec, password, key) | `InputState::masked(true)`, `unmask_value()` | | `.set_value(value, window, cx)` | `set_value(value, window, cx)` | | `InputEvent::{Change, PressEnter, Focus, Blur}` | identical variants | | `Input::new(&state).appearance(false)` | coop's `Input` keeps these chrome options | Known gaps to reconcile here, verified against the 0.6.1 source before starting: `clean_on_escape()`, `set_loading()` (called from `crates/workspace/src/sidebar/mod.rs`), and the `InputEditorStyle` hook that has to be filled from coop tokens. Everything else in `input/` — `display_map`, `rope_ext`, `mask_pattern`, `movement`, `selection`, `indent`, `mode`, `element` — is deleted. Afterwards, `ropey`, `sum_tree`, `lsp-types`, `tree-sitter`, `regex`, `unicode-segmentation`, and `uuid` can probably leave `crates/ui`'s manifest. Surfaces to re-verify: the chat composer (auto-grow, Enter to send, IME), the subject line, the settings dialog, profile, relay and messaging lists, the import/restore/backup dialogs, and sidebar search. ### Phase 3 — overlays and feedback `popover` becomes a wrapper over base `Popover`; `modal` composes base `Dialog` and `AlertDialog` while keeping the `Modal` API and `window.open_modal`; `notification` moves onto `Toast`/`ToastManager` (base owns the stack, timers, and motion; coop owns the visual and the placement from `theme.notification`); `tooltip` becomes a wrapper over base `Tooltip`. `Root` and `window_ext` keep their public API and host the new layers. No call site changes. ### Phase 4 — leaf controls, scroll, and resizable (one module per pull request) Order: `tooltip`, `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`, `loading`, and `caret` builders, and the variant names stay as they are, with styling supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement` trait name so `.vertical_scrollbar(..)` call sites keep compiling, and `resizable/` becomes a thin re-export of base's identically named API plus a `ResizeHandleRenderer` for the coop hairline. Each of these is independently shippable. Acceptance for each: no change outside `crates/ui`, and the surfaces that use the module are pixel-identical before and after. ### Phase 5 — dock, tab, and menu (deliberately later) Base has a full dock, but its contract is "layout is data, and the application implements the renderer traits", while coop's `Panel`/`PanelView`/`DockArea`/`DockItem` is an app-specific shell already consumed by `crates/workspace` and `crates/chat_ui`. Moving it is a project of its own, and it would also retire `tab/` and touch `menu/`. Keep them local until phases 1–4 have landed, then plan dock separately. Re-basing menu positioning and dismissal on base `Popup`/`Positioner` is optional and later still. ## Verification There is no UI test suite to lean on, so each phase gets the same treatment: - `cargo check` at the workspace root, plus `cargo check -p coop_web --target wasm32-unknown-unknown` for the web target. - Launch the app and walk the surfaces the phase touched. The settings dialog is the densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, PopupMenuItem), followed by the chat panel and the sidebar. - For phase 4, record before/after screenshots per module. - Keep the call-site diff at zero for phases 1, 3, and 4; if a call site has to change because base has no equivalent (`set_loading` is the known candidate), list it in the pull request. ## Risks and non-goals - **Snapshot lag.** The `gpui-pre` package is a republished snapshot, so it trails zed `main` by however long it takes longbridge to cut the next release (a few days). A new GPUI API is therefore unavailable until then. That is the price of not maintaining a fork; the escape hatch — vendoring `gpui-base` and patching it onto zed's git repository — should stay unused. - **The `gpui` dependency line is load-bearing.** Depending on zed's git `gpui` alongside `gpui-base` looks harmless and is not: it puts two GPUI crates in the graph and every window, context, and element crossing between them becomes a type error. - **Two `Theme` globals.** Confine `gpui_base::Theme` to `theme::sync_base` and `crates/ui` internals; application code keeps using `theme::ActiveTheme`. Avoid importing both `Theme` types into one file. - **`gpui_tokio`** has to be vendored or dropped (phase 0). - **Non-goals:** adopting `gpui-component`, migrating dock/tab/menu, rewriting the self-contained pieces (`icon`, `kbd`, `divider`, `skeleton`, `group_box`, `indicator`), and changing any color, radius, or spacing value. ## Pull request sequence | PR | Content | Touches outside `crates/ui` | | --- | --- | --- | | 1 | Phase 0: move `gpui` to the `gpui-pre` package, fix drift | `Cargo.toml`, possibly `crates/state` | | 2 | Phase 1: base wiring, `sync_base`, deletions | none | | 3 | Phase 2: input | none, or the named gaps above | | 4 | Phase 3: popover, modal, notification, tooltip | none | | 5–10 | Phase 4: one leaf module each | none | | later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | The end state: the application keeps its design system and its call sites, `crates/ui` shrinks by roughly half, and the parts that are genuinely hard — text editing, focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are maintained upstream instead of in a fork.