# 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`. ## Status - **Phase 0: landed.** Manifest only; no Rust changed. The API drift across the three days between the snapshot and the old pin turned out to be purely additive, so nothing had to be fixed. - **Phase 1: landed.** Base is wired in, `sync_base` is in place, and 1,945 lines of dead weight are gone. `history.rs` moved to phase 2 once it turned out its only consumer is `input/state.rs`. No dependency became unused, so the pruning step is a no-op (four dependencies were already unused before this work). - **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of engine and history are gone and 306 are written, taking `crates/ui/src/input` from 6,573 lines to 321. Six call sites changed, all named in phase 2 below. - **Phases 3-5: not started.** - One pre-existing, unrelated breakage was found; see [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). ## 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`, published 2026-09-14) republished unchanged, so nothing is forked and there is no source to align. Coop previously pinned zed's git repository at `4b47ceb` (2026-09-17), a few 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`; plus `TextareaState` and `Textarea` after phase 2) | 3, then 5 | 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` had no consumers at all — the message list in `crates/chat_ui` uses GPUI's own `list::ListState` — so phase 1 deleted both. The other 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 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: `theme::sync_base(cx)`, called from `ui::init` and from `Theme::change` so that it re-runs on every theme change. It is a no-op before coop's theme global exists, which is the case when `ui::init` runs ahead of `theme::init`; `Theme::change` is the hook that actually keeps the projection current. It projects the color roles base can act on — the focus ring, the wash under selected text, scrollbars, and overlay backdrops — and nothing else: | `gpui_base::ColorTokens` | coop `ThemeColors` | | --- | --- | | `background` / `foreground` | `background` / `text` | | `surface` / `surface_foreground` | `surface_background` / `text` | | `primary` / `primary_foreground` | `element_background` / `element_foreground` | | `secondary` / `secondary_foreground` | `secondary_background` / `secondary_foreground` | | `muted` / `muted_foreground` | `ghost_element_background_alt` / `text_muted` | | `accent` / `accent_foreground` | `ghost_element_hover` / `text` | | `destructive` / `destructive_foreground` | `danger_background` / `danger_foreground` | | `border` / `input` | `border` | | `ring` | `ring` | | `selection` | `selection` | It also sets `ThemeAppearance` from coop's mode, `ScrollbarTheme`'s mode from coop's `scrollbar_mode`, and `TypographyTokens::sans` from coop's `font_family`. Radius, spacing, typography sizes, shadows, and scrollbar geometry keep their base defaults: coop has a single `radius`/`radius_lg`/`font_size` where base has six-point scales, so any mapping would be invented rather than derived. `ResizableTheme` needs nothing — base's documented `None` fallback already resolves to `border` at rest and `ring` while dragging, both of which are projected. ## What each module becomes | `ui` module | LOC | Plan | `gpui-base` counterpart | | --- | --- | --- | --- | | `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame | | `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` | 184 | Delete. Base's input keeps its own `UndoManager`, and `UndoHistory` is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was `input/state.rs` | — | | `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based | | `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `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. Its `focused_input` field and the two `WindowExtension` methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them | — | 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" } gpui_util = { package = "gpui-pre-util", 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_tokio = { path = "crates/gpui_tokio" } gpui-base = "0.6.1" ``` Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep compiling unchanged. The aliases match the ones `gpui-pre` uses internally, and `gpui_web` moved out of `web/Cargo.toml` into this 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 crate in the family longbridge does not republish. `crates/state` uses it to run `browser-signer-proxy` and `nostr-blossom` work, and the nostr client's reqwest backend needs a Tokio reactor, so the runtime cannot be dropped for `cx.background_spawn`. It is vendored verbatim from zed at `4b47ceb` into `crates/gpui_tokio` (Apache-2.0, ~100 lines), which is the smallest change that keeps the existing behaviour. `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) — landed Point the workspace's GPUI entries at the published `gpui-pre` crates. There is no GPUI source to align, patch, or vendor. **No drift had to be fixed.** The gap between the snapshot (`zed@d89e9c2`) and the old pin (`4b47ceb`) is 67 commits, but only 16 touch the GPUI crates, and the public surface only gained names: `ShapedLineCursor`, `MissingGlyphSink`, `MissingGlyph`, `FallbackFontClass`, `MEASUREMENT_VERSION`, dynamic font installation, and inspector registration. Nothing coop used was removed or changed shape, so every `use gpui::…` compiled unchanged. The three entry points coop calls — `gpui_platform::application()`, `gpui_platform::web_init()`, and `gpui_platform::single_threaded_web()` — all exist in 0.3.5. Exit criteria: `cargo check` passes for `desktop`, and the wasm criterion is blocked by a pre-existing bug unrelated to GPUI — see [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). `cargo check -p theme -p ui --target wasm32-unknown-unknown`, which covers everything this migration touches, passes. 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) — landed What changed: - `crates/ui` and `crates/theme` take `gpui-base`. - `ui::init` calls `gpui_base::init(cx)` then `theme::sync_base(cx)`; the `list::init(cx)` call went with `list/`. - `ui`'s crate root re-exports `ElementExt`, `IndexPath`, and `InteractiveElementExt` from `gpui_base`, so existing `use ui::{…}` sites are unchanged. In particular `chat_ui`'s `.on_double_click(…)` is served by base's `InteractiveElementExt`, which is the same implementation as the fork's. - `ui::styled` no longer defines `Selectable`, `Disableable`, or `Collapsible`; it re-exports them from `gpui_base::component_traits`. All three are signature-identical to the fork's, so the `impl` blocks in `avatar`, `button`, `input`, and the rest compile untouched. The path is `component_traits` rather than the crate root because `gpui_base::Collapsible` is base's *component* of that name, not the trait. - Deleted: `checkbox.rs` (312), `list/` (1,477), `index_path.rs` (69), `element_ext.rs` (27), `event.rs` (21), `focusable.rs` (39) — 1,945 lines, with no external consumers and a base counterpart for everything except `FocusableCycle`. Two corrections this phase produced: - **`history.rs` moved to phase 2.** It maps to base's `UndoHistory`, not `History`: base's `History` is navigation (back/forward), while `UndoHistory` is the grouped undo/redo. Swapping it means editing `input/state.rs` — six `ignore` writes become `set_ignoring`, and `Change` loses its `HistoryItem` impl — which is phase 2's file. - **No dependency became unused.** `ropey`, `sum_tree`, `lsp-types`, `tree-sitter`, `regex`, `unicode-segmentation`, `uuid`, and `instant` are all still used by `input/` and `history.rs`, and the deleted files used none of the others, so pruning happens in phase 2. Separately, four dependencies — `common`, `anyhow`, `itertools`, and `smol` — were already unreferenced anywhere in `crates/ui/src` *before* this change. They are left alone here because removing them is unrelated to the migration. Exit criteria: no diff outside `crates/ui` and `crates/theme` — met; the only files touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and `theme/src/lib.rs`. `cargo check` and `cargo build` both pass with no warnings, and `theme` and `ui` still compile for `wasm32-unknown-unknown`. The remaining part of the acceptance — launching the app and walking the settings dialog and chat panel — has to be done by hand and has not been run. ### Phase 2 — `input/` (the largest single win) — landed `crates/ui/src/input` is three files and 321 lines: a rewritten 299-line `input.rs`, a 7-line `mod.rs` that re-exports base, and the untouched 15-line `clear_button.rs`. Deleted: `state.rs`, `element.rs`, `display_map/`, `rope_ext.rs`, `mask_pattern.rs`, `movement.rs`, `selection.rs`, `indent.rs`, `mode.rs`, `change.rs`, `cursor.rs`, `blink_cursor.rs`, and `history.rs` — 6,715 lines. The names the application imports are unchanged, but two of them are base's now: | Coop before | `ui::input` now | | --- | --- | | `InputState`, one struct that became multi-line through `auto_grow`/`multi_line` | `InputState` = `InputBaseState` and `TextareaState` = `InputBaseState`; multi-line is a property of the state's kind | | `Input`, one element that rendered whatever kind of state it was given | `Input` for `InputState` and `Textarea` for `TextareaState` — one generic element, two names | | `InputEvent::{Change, PressEnter, Focus, Blur}` | identical | | `history::History` and `HistoryItem` | gone; `Change` keeps no trait impl | The styled element is a frame around base's engine rather than the engine itself. Base's `InputBaseState::render` registers the key context, the focus handle, every editing action, the text element and the editor scrollbar, so the coop element no longer carries any of it. What is left is chrome — background, radius, font size, prefix and suffix slots, clear button, mask toggle, loading indicator — plus three projections onto the state: - `set_editor_style(InputEditorStyle)`, filling `foreground`, `muted_foreground`, `selection` and `caret` from `text`, `text_muted`, `selection` and `cursor`. Base resolves any color left transparent from its own palette, and that palette is only a projection of coop's, so every color coop paints with is named rather than left to resolve. The remaining fields stay at base's defaults: coop configures no highlighter, no diagnostics and no gutter. - `set_editor_paddings(Edges)`, for multi-line only, resolved from the same `Size` table the single-line frame applies, through the window's rem size. Base puts that padding on the text element itself so the text, the gutter and the scrollbar share one inset; putting it on the frame *and* passing it here would double it. Passing the frame's own value is also what keeps the scrollbar where the fork drew it. - `set_disabled` and `set_text_align`, replacing the fork's direct writes to `state.size`, `state.disabled` and `state.text_align`. `history.rs` folded in as predicted, with one correction: it did not need re-basing at all. Base's engine owns an `UndoManager`, and `gpui_base::UndoHistory` — the grouped undo/redo, not the back/forward `History` — is a separate utility the engine never reaches for. Removing `pub mod history` is safe because nothing outside `crates/ui` referenced it. The gaps named before the phase started all resolved in base's favor: `clean_on_escape()` and `set_loading()` both exist in 0.6.1, and `InputEditorStyle` is the third piece. **Six call sites changed, and none of them is churn:** | Call site | Change | Why | | --- | --- | --- | | `chat_ui` composer | `InputState` → `TextareaState`, `Input::new` → `Textarea::new` | multi-line is the state's kind, not a layout flag | | `workspace`, profile bio | the same, and `.multi_line(true)` is dropped | the same; `auto_grow(3, 8)` is unchanged | | `workspace`, sidebar | `set_loading(status, cx)` → `set_loading(status, window, cx)` | base's signature takes the window | | `workspace`, sidebar | the `.loading` field read → `.presentation().is_loading()` | `loading` is private; `InputPresentation` is the facade for reading it | | `ui::window_ext` | `focused_input` and `has_focused_input` are removed | their only implementation was the deleted paint hook, and no crate consumed them | | `ui::init` | `input::init(cx)` is removed | `gpui_base::init` binds the same keys, and its set is a strict superset | Three visible differences survive, all of them base's, none of them a color, radius or spacing value: - **The mask character is `•`, not `*`.** Base's `MASK_CHAR` is a private constant, so the fork's `*` cannot be restored. It shows only in masked inputs. - **The caret is `0.85 × line_height` at every size.** The fork scaled it by `Size` (0.75 at small, 1.0 at large) from a `size` field base does not have. - **Inputs are tab stops.** Base builds the state's focus handle with `tab_stop(true)`; the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them. Two things came along with `InputBase` that the fork's plain `div` did not do: the frame carries the `TextInput` accessibility role, and a left click anywhere in the frame — including the padding outside the text element — focuses the input. The second has to be restated on the frame because base handles its own mouse events on the inner element only. Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the profile bio, the subject line, the settings dialog, the relay and messaging lists, the import/restore/backup dialogs, and the sidebar search field. ### 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` and `cargo build` at the workspace root. - `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot be checked end to end until the pre-existing blocker below is fixed, so the migrated crates are checked directly. - 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 where the phase claims it — phases 1 and 3–4 do; if a call site has to change because base has no equivalent, list it in the pull request. Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent" turned out to mean in practice. ### A pre-existing wasm blocker `cargo check -p coop_web --target wasm32-unknown-unknown` fails while compiling `errno 0.3.14`, which refuses `wasm32-unknown-unknown`. The path is `coop_web → workspace → browser-signer-proxy → smol → async-io → rustix → errno`, none of which involves GPUI. `crates/workspace/Cargo.toml` declares `browser-signer-proxy`, but nothing under `crates/workspace/src` references it; the crate is only used by `crates/state`, where it is already gated `#[cfg(not(target_arch = "wasm32"))]`. Every version on that path (`errno 0.3.14`, `rustix 1.1.5`, `async-io 2.6.0`, `smol 2.0.2`) is identical before and after phase 0, and no file on it is part of this work, so the web build was already broken. The remedy is deleting that one stale dependency line, but that is unrelated to the migration and is deliberately left out. Until it is done, read the wasm exit criterion for phases 1-4 as "`theme` and `ui` compile for `wasm32-unknown-unknown`". ## 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` is vendored, not ours.** `crates/gpui_tokio` is zed's crate kept verbatim at `crates/gpui_tokio/src/lib.rs` because the `gpui-pre` family does not publish it and the nostr client needs a Tokio reactor. Re-sync or delete it if longbridge ever ships an equivalent. - **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` | Status | | --- | --- | --- | --- | | 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed | | 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed | | 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed | | 4 | Phase 3: popover, modal, notification, tooltip | none | not started | | 5–10 | Phase 4: one leaf module each | none | not started | | later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started | 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.